Compare commits
4 Commits
chore/add-
...
feature/gu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa00f6fe21 | ||
|
|
95aa2ef8ee | ||
|
|
e3443eedfb | ||
|
|
03bd3659e0 |
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(npm run type-check:*)",
|
||||||
|
"Bash(npm run:*)"
|
||||||
|
],
|
||||||
|
"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`
|
||||||
131
.cursor/rules/general-coding-guidelines.md
Normal file
131
.cursor/rules/general-coding-guidelines.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# General Coding Guidelines
|
||||||
|
|
||||||
|
## Rule Summary
|
||||||
|
Follow these rules when you write code:
|
||||||
|
|
||||||
|
1. **Use Early Returns**
|
||||||
|
- Prefer early returns and guard clauses to reduce nesting and improve readability, especially for error handling.
|
||||||
|
|
||||||
|
2. **Tailwind for Styling**
|
||||||
|
- Always use Tailwind CSS utility classes for styling HTML elements.
|
||||||
|
- Avoid writing custom CSS or using inline `style` tags.
|
||||||
|
|
||||||
|
3. **Class Tag Syntax**
|
||||||
|
- Use `class:` directive (e.g., `class:active={isActive}`) instead of the ternary operator in class tags whenever possible.
|
||||||
|
|
||||||
|
4. **Descriptive Naming**
|
||||||
|
- Use clear, descriptive names for variables, functions, and constants.
|
||||||
|
- Use auxiliary verbs for booleans and state (e.g., `isLoaded`, `hasError`, `shouldRender`).
|
||||||
|
- Event handler functions should be prefixed with `handle`, e.g., `handleClick` for `onClick`, `handleKeyDown` for `onKeyDown`.
|
||||||
|
|
||||||
|
5. **Naming Conventions**
|
||||||
|
- **Directories:** Use lowercase with dashes (e.g., `components/auth-wizard`).
|
||||||
|
- **Variables & Functions:** Use `camelCase` (e.g., `userList`, `fetchData`).
|
||||||
|
- **Types & Interfaces:** Use `PascalCase` (e.g., `User`, `ButtonProps`).
|
||||||
|
- **Exports:** Favor named exports for components.
|
||||||
|
- **No Unused Variables:** Remove unused variables and imports.
|
||||||
|
|
||||||
|
6. **File Layout**
|
||||||
|
- Order: exported component → subcomponents → hooks/helpers → static content.
|
||||||
|
|
||||||
|
7. **Props & Types**
|
||||||
|
- Define props with TypeScript `interface` or `type`, not `prop-types`.
|
||||||
|
- Example:
|
||||||
|
```ts
|
||||||
|
interface ButtonProps {
|
||||||
|
label: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ label, onClick }: ButtonProps) {
|
||||||
|
return <button onClick={onClick}>{label}</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Component Declaration**
|
||||||
|
- Use the `function` keyword for components, not arrow functions.
|
||||||
|
|
||||||
|
9. **Hooks Usage**
|
||||||
|
- Call hooks (e.g., `useState`, `useEffect`) only at the top level of components.
|
||||||
|
- Extract reusable logic into custom hooks (e.g., `useAuth`, `useFormValidation`).
|
||||||
|
|
||||||
|
10. **Memoization & Performance**
|
||||||
|
- Use `React.memo`, `useCallback`, and `useMemo` where appropriate.
|
||||||
|
- Avoid inline functions in JSX—pull handlers out or wrap in `useCallback`.
|
||||||
|
|
||||||
|
11. **Composition**
|
||||||
|
- Favor composition (render props, `children`) over inheritance.
|
||||||
|
|
||||||
|
12. **Code Splitting**
|
||||||
|
- Use `React.lazy` + `Suspense` for code splitting.
|
||||||
|
|
||||||
|
13. **Refs**
|
||||||
|
- Use refs only for direct DOM access.
|
||||||
|
|
||||||
|
14. **Forms**
|
||||||
|
- Prefer controlled components for forms.
|
||||||
|
|
||||||
|
15. **Error Boundaries**
|
||||||
|
- Implement an error boundary component for catching render errors.
|
||||||
|
|
||||||
|
16. **Effect Cleanup**
|
||||||
|
- Clean up effects in `useEffect` to prevent memory leaks.
|
||||||
|
|
||||||
|
17. **Accessibility**
|
||||||
|
- Apply appropriate ARIA attributes to interactive elements.
|
||||||
|
- For example, an `<a>` tag should have `tabindex="0"`, `aria-label`, `onClick`, and `onKeyDown` attributes as appropriate.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### ✅ Correct
|
||||||
|
```tsx
|
||||||
|
// File: components/user-profile.tsx
|
||||||
|
|
||||||
|
interface UserProfileProps {
|
||||||
|
user: User;
|
||||||
|
isLoaded: boolean;
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserProfile({ user, isLoaded, hasError }: UserProfileProps) {
|
||||||
|
if (!isLoaded) return <div>Loading...</div>;
|
||||||
|
if (hasError) return <div role="alert">Error loading user.</div>;
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
// ...
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="bg-blue-500 text-white"
|
||||||
|
aria-label="View user profile"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Incorrect
|
||||||
|
```tsx
|
||||||
|
// File: components/UserProfile.jsx
|
||||||
|
function userprofile(props) {
|
||||||
|
if (props.isLoaded) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button style={{ color: 'white' }} onClick={() => doSomething()}>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
- All new code must follow these guidelines.
|
||||||
|
- Code reviews should reject code that does not comply with these rules.
|
||||||
|
- Refactor existing code to follow these guidelines when making changes.
|
||||||
38
.cursor/rules/localization.md
Normal file
38
.cursor/rules/localization.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Localization Rule: No Hard-Coded User-Facing Text
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
- All user-facing text **must** be added to the localization system at `@/locales`.
|
||||||
|
- **Never** hard-code user-facing strings directly in components, pages, or business logic.
|
||||||
|
- Use the appropriate i18n or localization utility to fetch and display all text.
|
||||||
|
- **Always** provide a `defaultValue` when using the `t()` function for translations, e.g., `{t('emailPlaceholder', {defaultValue: 'Enter your email'})}`.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
- Ensures the application is fully translatable and accessible to all supported languages.
|
||||||
|
- Prevents missed strings during translation updates.
|
||||||
|
- Promotes consistency and maintainability.
|
||||||
|
- Providing a `defaultValue` ensures a fallback is shown if the translation key is missing.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### ✅ Correct
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return <input placeholder={t('emailPlaceholder', { defaultValue: 'Enter your email' })} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Incorrect
|
||||||
|
```tsx
|
||||||
|
return <input placeholder={t('emailPlaceholder')} />;
|
||||||
|
|
||||||
|
// or
|
||||||
|
return <input placeholder="Enter your email" />;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
- All new user-facing text **must** be added to the appropriate file in `@/locales`.
|
||||||
|
- Every use of `t()` **must** include a `defaultValue` for fallback.
|
||||||
|
- Code reviews should reject any hard-coded user-facing strings or missing `defaultValue` in translations.
|
||||||
|
- Refactor existing hard-coded text to use the localization system and add `defaultValue` when modifying related code.
|
||||||
39
.cursor/rules/react-component-naming.md
Normal file
39
.cursor/rules/react-component-naming.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# React Component Naming Rule: PascalCase
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
- All React component names **must** use PascalCase.
|
||||||
|
- This applies to:
|
||||||
|
- Component file names (e.g., `MyComponent.tsx`, `UserProfile.jsx`)
|
||||||
|
- Exported component identifiers (e.g., `export const MyComponent = ...` or `function UserProfile() { ... }`)
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
- PascalCase is the community standard for React components.
|
||||||
|
- Ensures consistency and readability across the codebase.
|
||||||
|
- Prevents confusion between components and regular functions/variables.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### ✅ Correct
|
||||||
|
```tsx
|
||||||
|
// File: UserProfile.tsx
|
||||||
|
export function UserProfile() { ... }
|
||||||
|
|
||||||
|
// File: TaskList.tsx
|
||||||
|
const TaskList = () => { ... }
|
||||||
|
export default TaskList;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Incorrect
|
||||||
|
```tsx
|
||||||
|
// File: userprofile.tsx
|
||||||
|
export function userprofile() { ... }
|
||||||
|
|
||||||
|
// File: task-list.jsx
|
||||||
|
const task_list = () => { ... }
|
||||||
|
export default task_list;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
- All new React components **must** follow this rule.
|
||||||
|
- Refactor existing components to PascalCase when modifying or moving them.
|
||||||
|
- Code reviews should reject non-PascalCase component names.
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,8 +36,6 @@ lerna-debug.log*
|
|||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea/
|
.idea/
|
||||||
.cursor/
|
|
||||||
.claude/
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.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
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
-- Migration: Add survey tables for account setup questionnaire
|
|
||||||
-- Date: 2025-07-24
|
|
||||||
-- Description: Creates tables to store survey questions and user responses for account setup flow
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- Create surveys table to define different types of surveys
|
|
||||||
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, -- 'account_setup', 'onboarding', 'feedback'
|
|
||||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create survey_questions table to store individual questions
|
|
||||||
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, -- Used for localization keys
|
|
||||||
question_type VARCHAR(50) NOT NULL, -- 'single_choice', 'multiple_choice', 'text'
|
|
||||||
is_required BOOLEAN DEFAULT FALSE NOT NULL,
|
|
||||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
|
||||||
options JSONB, -- For choice questions, store options as JSON array
|
|
||||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create survey_responses table to track user responses to surveys
|
|
||||||
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 survey_answers table to store individual question answers
|
|
||||||
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, -- For multiple choice answers stored as array
|
|
||||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
|
||||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Add performance 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);
|
|
||||||
|
|
||||||
-- Add 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'));
|
|
||||||
|
|
||||||
-- Add unique constraint to prevent duplicate responses per user per survey
|
|
||||||
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
|
|
||||||
|
|
||||||
-- Add unique constraint to prevent duplicate answers per question per response
|
|
||||||
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);
|
|
||||||
|
|
||||||
-- Insert the 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;
|
|
||||||
|
|
||||||
-- Get the survey ID for inserting questions
|
|
||||||
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 $$;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -2297,60 +2297,3 @@ ALTER TABLE organization_working_days
|
|||||||
ALTER TABLE organization_working_days
|
ALTER TABLE organization_working_days
|
||||||
ADD CONSTRAINT org_organization_id_fk
|
ADD CONSTRAINT org_organization_id_fk
|
||||||
FOREIGN KEY (organization_id) REFERENCES organizations;
|
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)
|
INSERT INTO timezones (name, abbrev, utc_offset)
|
||||||
SELECT name, abbrev, utc_offset
|
SELECT name, abbrev, utc_offset
|
||||||
FROM pg_timezone_names;
|
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 TeamMembersController from "./team-members-controller";
|
||||||
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
||||||
import {updateUsers} from "../shared/paddle-requests";
|
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";
|
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||||
|
|
||||||
export default class ProjectMembersController extends WorklenzControllerBase {
|
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."));
|
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 (subscriptionData.status === "trialing") break;
|
||||||
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
||||||
// if (subscriptionData.subscription_status === "active") {
|
// if (subscriptionData.subscription_status === "active") {
|
||||||
|
|||||||
@@ -317,58 +317,65 @@ export default class ProjectsController extends WorklenzControllerBase {
|
|||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||||
const search = (req.query.search || "").toString().trim();
|
|
||||||
|
|
||||||
let searchFilter = "";
|
|
||||||
const params = [req.params.id, req.user?.team_id ?? null, size, offset];
|
|
||||||
if (search) {
|
|
||||||
searchFilter = `
|
|
||||||
AND (
|
|
||||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
|
||||||
OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
params.push(search);
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
WITH filtered_members AS (
|
SELECT ROW_TO_JSON(rec) AS members
|
||||||
SELECT project_members.id,
|
FROM (SELECT COUNT(*) AS total,
|
||||||
|
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||||
|
FROM (SELECT project_members.id,
|
||||||
team_member_id,
|
team_member_id,
|
||||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
|
(SELECT name
|
||||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
FROM team_member_info_view
|
||||||
|
WHERE team_member_info_view.team_member_id = tm.id),
|
||||||
|
(SELECT email
|
||||||
|
FROM team_member_info_view
|
||||||
|
WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||||
u.avatar_url,
|
u.avatar_url,
|
||||||
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
(SELECT COUNT(*)
|
||||||
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
FROM tasks
|
||||||
EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
|
WHERE archived IS FALSE
|
||||||
(SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
AND project_id = project_members.project_id
|
||||||
|
AND id IN (SELECT task_id
|
||||||
|
FROM tasks_assignees
|
||||||
|
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM tasks
|
||||||
|
WHERE archived IS FALSE
|
||||||
|
AND project_id = project_members.project_id
|
||||||
|
AND id IN (SELECT task_id
|
||||||
|
FROM tasks_assignees
|
||||||
|
WHERE tasks_assignees.project_member_id = project_members.id)
|
||||||
|
AND status_id IN (SELECT id
|
||||||
|
FROM task_statuses
|
||||||
|
WHERE category_id = (SELECT id
|
||||||
|
FROM sys_task_status_categories
|
||||||
|
WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||||
|
EXISTS(SELECT email
|
||||||
|
FROM email_invitations
|
||||||
|
WHERE team_member_id = project_members.team_member_id
|
||||||
|
AND email_invitations.team_id = $2) AS pending_invitation,
|
||||||
|
(SELECT project_access_levels.name
|
||||||
|
FROM project_access_levels
|
||||||
|
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||||
FROM project_members
|
FROM project_members
|
||||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||||
LEFT JOIN users u ON tm.user_id = u.id
|
LEFT JOIN users u ON tm.user_id = u.id
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
${search ? searchFilter : ""}
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM filtered_members) AS total,
|
|
||||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
|
||||||
FROM (
|
|
||||||
SELECT * FROM filtered_members
|
|
||||||
ORDER BY ${sortField} ${sortOrder}
|
ORDER BY ${sortField} ${sortOrder}
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4) t) AS data
|
||||||
) t
|
FROM project_members
|
||||||
) AS data
|
WHERE project_id = $1) rec;
|
||||||
`;
|
`;
|
||||||
|
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
|
||||||
const result = await db.query(q, params);
|
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
|
|
||||||
for (const member of data?.data || []) {
|
for (const member of data?.members.data || []) {
|
||||||
member.progress = member.all_tasks_count > 0
|
member.progress = member.all_tasks_count > 0
|
||||||
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
|
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct));
|
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
|
||||||
}
|
}
|
||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
// Example of updated getMemberTimeSheets method with timezone support
|
|
||||||
// This shows the key changes needed to handle timezones properly
|
|
||||||
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import db from "../../config/db";
|
|
||||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
|
||||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
|
||||||
import { ServerResponse } from "../../models/server-response";
|
|
||||||
import { DATE_RANGES } from "../../shared/constants";
|
|
||||||
|
|
||||||
export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
const archived = req.query.archived === "true";
|
|
||||||
const teams = (req.body.teams || []) as string[];
|
|
||||||
const teamIds = teams.map(id => `'${id}'`).join(",");
|
|
||||||
const projects = (req.body.projects || []) as string[];
|
|
||||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
|
||||||
const {billable} = req.body;
|
|
||||||
|
|
||||||
// Get user timezone from request or database
|
|
||||||
const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || "");
|
|
||||||
|
|
||||||
if (!teamIds || !projectIds.length)
|
|
||||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
|
||||||
|
|
||||||
const { duration, date_range } = req.body;
|
|
||||||
|
|
||||||
// Calculate date range with timezone support
|
|
||||||
let startDate: moment.Moment;
|
|
||||||
let endDate: moment.Moment;
|
|
||||||
|
|
||||||
if (date_range && date_range.length === 2) {
|
|
||||||
// Convert user's local dates to their timezone's start/end of day
|
|
||||||
startDate = moment.tz(date_range[0], userTimezone).startOf("day");
|
|
||||||
endDate = moment.tz(date_range[1], userTimezone).endOf("day");
|
|
||||||
} else if (duration === DATE_RANGES.ALL_TIME) {
|
|
||||||
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
|
||||||
const minDateResult = await db.query(minDateQuery, []);
|
|
||||||
const minDate = minDateResult.rows[0]?.min_date;
|
|
||||||
startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone);
|
|
||||||
endDate = moment.tz(userTimezone);
|
|
||||||
} else {
|
|
||||||
// Calculate ranges based on user's timezone
|
|
||||||
const now = moment.tz(userTimezone);
|
|
||||||
|
|
||||||
switch (duration) {
|
|
||||||
case DATE_RANGES.YESTERDAY:
|
|
||||||
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("isoWeek");
|
|
||||||
endDate = now.clone().subtract(1, "week").endOf("isoWeek");
|
|
||||||
break;
|
|
||||||
case DATE_RANGES.LAST_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");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
startDate = now.clone().startOf("day");
|
|
||||||
endDate = now.clone().endOf("day");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to UTC for database queries
|
|
||||||
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
|
|
||||||
// Calculate working days in user's timezone
|
|
||||||
const totalDays = endDate.diff(startDate, "days") + 1;
|
|
||||||
let workingDays = 0;
|
|
||||||
|
|
||||||
const current = startDate.clone();
|
|
||||||
while (current.isSameOrBefore(endDate, "day")) {
|
|
||||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
|
||||||
workingDays++;
|
|
||||||
}
|
|
||||||
current.add(1, "day");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updated SQL query with proper timezone handling
|
|
||||||
const billableQuery = buildBillableQuery(billable);
|
|
||||||
const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`;
|
|
||||||
|
|
||||||
const q = `
|
|
||||||
WITH project_hours AS (
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
COALESCE(hours_per_day, 8) as hours_per_day
|
|
||||||
FROM projects
|
|
||||||
WHERE id IN (${projectIds})
|
|
||||||
),
|
|
||||||
total_working_hours AS (
|
|
||||||
SELECT
|
|
||||||
SUM(hours_per_day) * ${workingDays} as total_hours
|
|
||||||
FROM project_hours
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.email,
|
|
||||||
tm.name,
|
|
||||||
tm.color_code,
|
|
||||||
COALESCE(SUM(twl.time_spent), 0) as logged_time,
|
|
||||||
COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value,
|
|
||||||
(SELECT total_hours FROM total_working_hours) as total_working_hours,
|
|
||||||
CASE
|
|
||||||
WHEN (SELECT total_hours FROM total_working_hours) > 0
|
|
||||||
THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2)
|
|
||||||
ELSE 0
|
|
||||||
END as utilization_percent,
|
|
||||||
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours,
|
|
||||||
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours,
|
|
||||||
'${userTimezone}' as user_timezone,
|
|
||||||
'${startDate.format("YYYY-MM-DD")}' as report_start_date,
|
|
||||||
'${endDate.format("YYYY-MM-DD")}' as report_end_date
|
|
||||||
FROM team_members tm
|
|
||||||
LEFT JOIN users u ON tm.user_id = u.id
|
|
||||||
LEFT JOIN task_work_log twl ON twl.user_id = u.id
|
|
||||||
LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery}
|
|
||||||
LEFT JOIN projects p ON t.project_id = p.id
|
|
||||||
WHERE tm.team_id IN (${teamIds})
|
|
||||||
AND (
|
|
||||||
twl.id IS NULL
|
|
||||||
OR (
|
|
||||||
p.id IN (${projectIds})
|
|
||||||
AND twl.created_at >= '${startUtc}'::TIMESTAMP
|
|
||||||
AND twl.created_at <= '${endUtc}'::TIMESTAMP
|
|
||||||
${archivedClause}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
GROUP BY u.id, u.email, tm.name, tm.color_code
|
|
||||||
ORDER BY logged_time DESC`;
|
|
||||||
|
|
||||||
const result = await db.query(q, []);
|
|
||||||
|
|
||||||
// Add timezone context to response
|
|
||||||
const response = {
|
|
||||||
data: result.rows,
|
|
||||||
timezone_info: {
|
|
||||||
user_timezone: userTimezone,
|
|
||||||
report_period: {
|
|
||||||
start: startDate.format("YYYY-MM-DD HH:mm:ss z"),
|
|
||||||
end: endDate.format("YYYY-MM-DD HH:mm:ss z"),
|
|
||||||
working_days: workingDays,
|
|
||||||
total_days: totalDays
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, response));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUserTimezone(userId: string): Promise<string> {
|
|
||||||
const q = `SELECT tz.name as timezone
|
|
||||||
FROM users u
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string {
|
|
||||||
if (!billable) return "";
|
|
||||||
|
|
||||||
const { billable: isBillable, nonBillable } = billable;
|
|
||||||
|
|
||||||
if (isBillable && nonBillable) {
|
|
||||||
return "";
|
|
||||||
} else if (isBillable) {
|
|
||||||
return " AND tasks.billable IS TRUE";
|
|
||||||
} else if (nonBillable) {
|
|
||||||
return " AND tasks.billable IS FALSE";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
|
||||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
|
||||||
import db from "../../config/db";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
import { DATE_RANGES } from "../../shared/constants";
|
|
||||||
|
|
||||||
export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user's timezone from the database or request
|
|
||||||
* @param userId - The user ID
|
|
||||||
* @returns The user's timezone or 'UTC' as default
|
|
||||||
*/
|
|
||||||
protected static async getUserTimezone(userId: string): Promise<string> {
|
|
||||||
const q = `SELECT tz.name as timezone
|
|
||||||
FROM users u
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate date range clause with timezone support
|
|
||||||
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
|
|
||||||
* @param dateRange - Array of date strings
|
|
||||||
* @param userTimezone - User's timezone (e.g., 'America/New_York')
|
|
||||||
* @returns SQL clause for date filtering
|
|
||||||
*/
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For predefined ranges, calculate based on user's timezone
|
|
||||||
const now = moment.tz(userTimezone);
|
|
||||||
let startDate, endDate;
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case DATE_RANGES.YESTERDAY:
|
|
||||||
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");
|
|
||||||
break;
|
|
||||||
case DATE_RANGES.LAST_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");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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 "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format dates for display in user's timezone
|
|
||||||
* @param date - Date to format
|
|
||||||
* @param userTimezone - User's timezone
|
|
||||||
* @param format - Moment format string
|
|
||||||
* @returns Formatted date string
|
|
||||||
*/
|
|
||||||
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
|
|
||||||
return moment.tz(date, userTimezone).format(format);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get working days count between two dates in user's timezone
|
|
||||||
* @param startDate - Start date
|
|
||||||
* @param endDate - End date
|
|
||||||
* @param userTimezone - User's timezone
|
|
||||||
* @returns Number of working days
|
|
||||||
*/
|
|
||||||
protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number {
|
|
||||||
const start = moment.tz(startDate, userTimezone);
|
|
||||||
const end = moment.tz(endDate, userTimezone);
|
|
||||||
let workingDays = 0;
|
|
||||||
|
|
||||||
const current = start.clone();
|
|
||||||
while (current.isSameOrBefore(end, "day")) {
|
|
||||||
// Monday = 1, Friday = 5
|
|
||||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
|
||||||
workingDays++;
|
|
||||||
}
|
|
||||||
current.add(1, "day");
|
|
||||||
}
|
|
||||||
|
|
||||||
return workingDays;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,69 +6,10 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
|||||||
import { ServerResponse } from "../../models/server-response";
|
import { ServerResponse } from "../../models/server-response";
|
||||||
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
|
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
|
||||||
import { formatDuration, getColor, int } from "../../shared/utils";
|
import { formatDuration, getColor, int } from "../../shared/utils";
|
||||||
import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone";
|
import ReportingControllerBase from "./reporting-controller-base";
|
||||||
import Excel from "exceljs";
|
import Excel from "exceljs";
|
||||||
|
|
||||||
export default class ReportingMembersController extends ReportingControllerBaseWithTimezone {
|
export default class ReportingMembersController extends ReportingControllerBase {
|
||||||
|
|
||||||
protected static getPercentage(n: number, total: number) {
|
|
||||||
return +(n ? (n / total) * 100 : 0).toFixed();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static getCurrentTeamId(req: IWorkLenzRequest): string | null {
|
|
||||||
return req.user?.team_id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static convertMinutesToHoursAndMinutes(totalMinutes: number) {
|
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
|
||||||
const minutes = totalMinutes % 60;
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static convertSecondsToHoursAndMinutes(seconds: number) {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static formatEndDate(endDate: string) {
|
|
||||||
const end = moment(endDate).format("YYYY-MM-DD");
|
|
||||||
const fEndDate = moment(end);
|
|
||||||
return fEndDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static formatCurrentDate() {
|
|
||||||
const current = moment().format("YYYY-MM-DD");
|
|
||||||
const fCurrentDate = moment(current);
|
|
||||||
return fCurrentDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static getDaysLeft(endDate: string): number | null {
|
|
||||||
if (!endDate) return null;
|
|
||||||
|
|
||||||
const fCurrentDate = this.formatCurrentDate();
|
|
||||||
const fEndDate = this.formatEndDate(endDate);
|
|
||||||
|
|
||||||
return fEndDate.diff(fCurrentDate, "days");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static isOverdue(endDate: string): boolean {
|
|
||||||
if (!endDate) return false;
|
|
||||||
|
|
||||||
const fCurrentDate = this.formatCurrentDate();
|
|
||||||
const fEndDate = this.formatEndDate(endDate);
|
|
||||||
|
|
||||||
return fEndDate.isBefore(fCurrentDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static isToday(endDate: string): boolean {
|
|
||||||
if (!endDate) return false;
|
|
||||||
|
|
||||||
const fCurrentDate = this.formatCurrentDate();
|
|
||||||
const fEndDate = this.formatEndDate(endDate);
|
|
||||||
|
|
||||||
return fEndDate.isSame(fCurrentDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async getMembers(
|
private static async getMembers(
|
||||||
teamId: string, searchQuery = "",
|
teamId: string, searchQuery = "",
|
||||||
@@ -546,9 +487,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
|||||||
dateRange = date_range.split(",");
|
dateRange = date_range.split(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user timezone for proper date filtering
|
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl");
|
||||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
|
||||||
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
|
|
||||||
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
||||||
const memberName = (req.query.member_name as string)?.trim() || null;
|
const memberName = (req.query.member_name as string)?.trim() || null;
|
||||||
|
|
||||||
@@ -1099,9 +1038,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
|||||||
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
|
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
|
||||||
|
|
||||||
// Get user timezone for proper date filtering
|
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl");
|
||||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
|
||||||
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
|
|
||||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
||||||
|
|
||||||
const billableQuery = this.buildBillableQuery(billable);
|
const billableQuery = this.buildBillableQuery(billable);
|
||||||
@@ -1293,8 +1230,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
|
|||||||
row.actual_time = int(row.actual_time);
|
row.actual_time = int(row.actual_time);
|
||||||
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
|
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
|
||||||
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
|
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
|
||||||
row.days_left = this.getDaysLeft(row.end_date);
|
row.days_left = ReportingControllerBase.getDaysLeft(row.end_date);
|
||||||
row.is_overdue = this.isOverdue(row.end_date);
|
row.is_overdue = ReportingControllerBase.isOverdue(row.end_date);
|
||||||
if (row.days_left && row.is_overdue) {
|
if (row.days_left && row.is_overdue) {
|
||||||
row.days_left = row.days_left.toString().replace(/-/g, "");
|
row.days_left = row.days_left.toString().replace(/-/g, "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,201 +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 { ISurveySubmissionRequest } from "../interfaces/survey";
|
|
||||||
import db from "../config/db";
|
|
||||||
|
|
||||||
export default class SurveyController extends WorklenzControllerBase {
|
|
||||||
@HandleExceptions()
|
|
||||||
public static async getAccountSetupSurvey(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
const q = `
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
s.name,
|
|
||||||
s.description,
|
|
||||||
s.survey_type,
|
|
||||||
s.is_active,
|
|
||||||
COALESCE(
|
|
||||||
json_agg(
|
|
||||||
json_build_object(
|
|
||||||
'id', sq.id,
|
|
||||||
'survey_id', sq.survey_id,
|
|
||||||
'question_key', sq.question_key,
|
|
||||||
'question_type', sq.question_type,
|
|
||||||
'is_required', sq.is_required,
|
|
||||||
'sort_order', sq.sort_order,
|
|
||||||
'options', sq.options
|
|
||||||
) ORDER BY sq.sort_order
|
|
||||||
) FILTER (WHERE sq.id IS NOT NULL),
|
|
||||||
'[]'
|
|
||||||
) AS questions
|
|
||||||
FROM surveys s
|
|
||||||
LEFT JOIN survey_questions sq ON s.id = sq.survey_id
|
|
||||||
WHERE s.survey_type = 'account_setup' AND s.is_active = true
|
|
||||||
GROUP BY s.id, s.name, s.description, s.survey_type, s.is_active
|
|
||||||
LIMIT 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await db.query(q);
|
|
||||||
const [survey] = result.rows;
|
|
||||||
|
|
||||||
if (!survey) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, "Account setup survey not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, survey));
|
|
||||||
}
|
|
||||||
|
|
||||||
@HandleExceptions()
|
|
||||||
public static async submitSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
const body = req.body as ISurveySubmissionRequest;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.survey_id || !body.answers || !Array.isArray(body.answers)) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, "Invalid survey submission data"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has already submitted a response for this survey
|
|
||||||
const existingResponseQuery = `
|
|
||||||
SELECT id FROM survey_responses
|
|
||||||
WHERE user_id = $1 AND survey_id = $2;
|
|
||||||
`;
|
|
||||||
const existingResult = await db.query(existingResponseQuery, [userId, body.survey_id]);
|
|
||||||
|
|
||||||
let responseId: string;
|
|
||||||
|
|
||||||
if (existingResult.rows.length > 0) {
|
|
||||||
// Update existing response
|
|
||||||
responseId = existingResult.rows[0].id;
|
|
||||||
|
|
||||||
const updateResponseQuery = `
|
|
||||||
UPDATE survey_responses
|
|
||||||
SET is_completed = true, completed_at = NOW(), updated_at = NOW()
|
|
||||||
WHERE id = $1;
|
|
||||||
`;
|
|
||||||
await db.query(updateResponseQuery, [responseId]);
|
|
||||||
|
|
||||||
// Delete existing answers
|
|
||||||
const deleteAnswersQuery = `DELETE FROM survey_answers WHERE response_id = $1;`;
|
|
||||||
await db.query(deleteAnswersQuery, [responseId]);
|
|
||||||
} else {
|
|
||||||
// Create new response
|
|
||||||
const createResponseQuery = `
|
|
||||||
INSERT INTO survey_responses (survey_id, user_id, is_completed, completed_at)
|
|
||||||
VALUES ($1, $2, true, NOW())
|
|
||||||
RETURNING id;
|
|
||||||
`;
|
|
||||||
const responseResult = await db.query(createResponseQuery, [body.survey_id, userId]);
|
|
||||||
responseId = responseResult.rows[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new answers
|
|
||||||
if (body.answers.length > 0) {
|
|
||||||
const answerValues: string[] = [];
|
|
||||||
const params: any[] = [];
|
|
||||||
|
|
||||||
body.answers.forEach((answer, index) => {
|
|
||||||
const baseIndex = index * 4;
|
|
||||||
answerValues.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`);
|
|
||||||
|
|
||||||
params.push(
|
|
||||||
responseId,
|
|
||||||
answer.question_id,
|
|
||||||
answer.answer_text || null,
|
|
||||||
answer.answer_json ? JSON.stringify(answer.answer_json) : null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const insertAnswersQuery = `
|
|
||||||
INSERT INTO survey_answers (response_id, question_id, answer_text, answer_json)
|
|
||||||
VALUES ${answerValues.join(', ')};
|
|
||||||
`;
|
|
||||||
|
|
||||||
await db.query(insertAnswersQuery, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, { response_id: responseId }));
|
|
||||||
}
|
|
||||||
|
|
||||||
@HandleExceptions()
|
|
||||||
public static async getUserSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
const surveyId = req.params.survey_id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = `
|
|
||||||
SELECT
|
|
||||||
sr.id,
|
|
||||||
sr.survey_id,
|
|
||||||
sr.user_id,
|
|
||||||
sr.is_completed,
|
|
||||||
sr.started_at,
|
|
||||||
sr.completed_at,
|
|
||||||
COALESCE(
|
|
||||||
json_agg(
|
|
||||||
json_build_object(
|
|
||||||
'question_id', sa.question_id,
|
|
||||||
'answer_text', sa.answer_text,
|
|
||||||
'answer_json', sa.answer_json
|
|
||||||
)
|
|
||||||
) FILTER (WHERE sa.id IS NOT NULL),
|
|
||||||
'[]'
|
|
||||||
) AS answers
|
|
||||||
FROM survey_responses sr
|
|
||||||
LEFT JOIN survey_answers sa ON sr.id = sa.response_id
|
|
||||||
WHERE sr.user_id = $1 AND sr.survey_id = $2
|
|
||||||
GROUP BY sr.id, sr.survey_id, sr.user_id, sr.is_completed, sr.started_at, sr.completed_at;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await db.query(q, [userId, surveyId]);
|
|
||||||
const [response] = result.rows;
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, "Survey response not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
import HandleExceptions from "../decorators/handle-exceptions";
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
import { formatDuration, getColor } from "../shared/utils";
|
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 { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
||||||
import { updateUsers } from "../shared/paddle-requests";
|
import { updateUsers } from "../shared/paddle-requests";
|
||||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
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 {
|
export default class TeamMembersController extends WorklenzControllerBase {
|
||||||
|
|
||||||
@@ -76,8 +72,7 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
@HandleExceptions({
|
@HandleExceptions({
|
||||||
raisedExceptions: {
|
raisedExceptions: {
|
||||||
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`,
|
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`
|
||||||
"ERROR_SPAM_DETECTED": `Invitation blocked: {0}`
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
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."));
|
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.
|
* Checks the subscription status of the team.
|
||||||
* @type {Object} subscriptionData - Object containing subscription information
|
* @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."));
|
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.
|
* Checks subscription details and updates the user count if applicable.
|
||||||
* Sends a response if there is an issue with the subscription.
|
* 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."));
|
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.status === "trialing") break;
|
||||||
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
||||||
if (subscriptionData.subscription_status === "active") {
|
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,37 +0,0 @@
|
|||||||
export interface ISurveyQuestion {
|
|
||||||
id: string;
|
|
||||||
survey_id: string;
|
|
||||||
question_key: string;
|
|
||||||
question_type: 'single_choice' | 'multiple_choice' | 'text';
|
|
||||||
is_required: boolean;
|
|
||||||
sort_order: number;
|
|
||||||
options?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISurvey {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
survey_type: 'account_setup' | 'onboarding' | 'feedback';
|
|
||||||
is_active: boolean;
|
|
||||||
questions?: ISurveyQuestion[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISurveyAnswer {
|
|
||||||
question_id: string;
|
|
||||||
answer_text?: string;
|
|
||||||
answer_json?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISurveyResponse {
|
|
||||||
id?: string;
|
|
||||||
survey_id: string;
|
|
||||||
user_id?: string;
|
|
||||||
is_completed: boolean;
|
|
||||||
answers: ISurveyAnswer[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISurveySubmissionRequest {
|
|
||||||
survey_id: string;
|
|
||||||
answers: ISurveyAnswer[];
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +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 { ISurveySubmissionRequest } from "../../interfaces/survey";
|
|
||||||
|
|
||||||
export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
|
|
||||||
const body = req.body as ISurveySubmissionRequest;
|
|
||||||
|
|
||||||
if (!body) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, "Request body is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.survey_id || typeof body.survey_id !== 'string') {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, "Survey ID is required and must be a string"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.answers || !Array.isArray(body.answers)) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, "Answers are required and must be an array"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each answer
|
|
||||||
for (let i = 0; i < body.answers.length; i++) {
|
|
||||||
const answer = body.answers[i];
|
|
||||||
|
|
||||||
if (!answer.question_id || typeof answer.question_id !== 'string') {
|
|
||||||
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
|
|
||||||
|
|
||||||
// Validate answer_text if provided
|
|
||||||
if (answer.answer_text && typeof answer.answer_text !== 'string') {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_text must be a string`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate answer_json if provided
|
|
||||||
if (answer.answer_json && !Array.isArray(answer.answer_json)) {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json must be an array`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate answer_json items are strings
|
|
||||||
if (answer.answer_json) {
|
|
||||||
for (let j = 0; j < answer.answer_json.length; j++) {
|
|
||||||
if (typeof answer.answer_json[j] !== 'string') {
|
|
||||||
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json items must be strings`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,6 @@ import {log_error} from "../../shared/utils";
|
|||||||
import db from "../../config/db";
|
import db from "../../config/db";
|
||||||
import {Request} from "express";
|
import {Request} from "express";
|
||||||
import {ERROR_KEY, SUCCESS_KEY} from "./passport-constants";
|
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) {
|
async function isGoogleAccountFound(email: string) {
|
||||||
const q = `
|
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"));
|
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);
|
const googleAccountFound = await isGoogleAccountFound(email);
|
||||||
if (googleAccountFound)
|
if (googleAccountFound)
|
||||||
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
|
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);
|
sendWelcomeEmail(email, name);
|
||||||
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -81,12 +81,5 @@
|
|||||||
"delete": "Fshi",
|
"delete": "Fshi",
|
||||||
"enterStatusName": "Shkruani emrin e statusit",
|
"enterStatusName": "Shkruani emrin e statusit",
|
||||||
"selectCategory": "Zgjidh kategorinë",
|
"selectCategory": "Zgjidh kategorinë",
|
||||||
"close": "Mbyll",
|
"close": "Mbyll"
|
||||||
"clearSort": "Pastro Renditjen",
|
|
||||||
"sortAscending": "Rendit në Rritje",
|
|
||||||
"sortDescending": "Rendit në Zbritje",
|
|
||||||
"sortByField": "Rendit sipas {{field}}",
|
|
||||||
"ascendingOrder": "Rritës",
|
|
||||||
"descendingOrder": "Zbritës",
|
|
||||||
"currentSort": "Renditja aktuale: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,12 +81,5 @@
|
|||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"enterStatusName": "Statusnamen eingeben",
|
"enterStatusName": "Statusnamen eingeben",
|
||||||
"selectCategory": "Kategorie auswählen",
|
"selectCategory": "Kategorie auswählen",
|
||||||
"close": "Schließen",
|
"close": "Schließen"
|
||||||
"clearSort": "Sortierung löschen",
|
|
||||||
"sortAscending": "Aufsteigend sortieren",
|
|
||||||
"sortDescending": "Absteigend sortieren",
|
|
||||||
"sortByField": "Sortieren nach {{field}}",
|
|
||||||
"ascendingOrder": "Aufsteigend",
|
|
||||||
"descendingOrder": "Absteigend",
|
|
||||||
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
|
||||||
"setupYourAccount": "Setup Your Account.",
|
"setupYourAccount": "Setup Your Worklenz Account.",
|
||||||
"organizationStepTitle": "Name Your Organization",
|
"organizationStepTitle": "Name Your Organization",
|
||||||
"organizationStepLabel": "Pick a name for your Worklenz account.",
|
"organizationStepLabel": "Pick a name for your Worklenz account.",
|
||||||
|
|
||||||
|
|||||||
@@ -81,12 +81,5 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"enterStatusName": "Enter status name",
|
"enterStatusName": "Enter status name",
|
||||||
"selectCategory": "Select category",
|
"selectCategory": "Select category",
|
||||||
"close": "Close",
|
"close": "Close"
|
||||||
"clearSort": "Clear Sort",
|
|
||||||
"sortAscending": "Sort Ascending",
|
|
||||||
"sortDescending": "Sort Descending",
|
|
||||||
"sortByField": "Sort by {{field}}",
|
|
||||||
"ascendingOrder": "Ascending",
|
|
||||||
"descendingOrder": "Descending",
|
|
||||||
"currentSort": "Current sort: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,12 +77,5 @@
|
|||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"enterStatusName": "Introducir nombre del estado",
|
"enterStatusName": "Introducir nombre del estado",
|
||||||
"selectCategory": "Seleccionar categoría",
|
"selectCategory": "Seleccionar categoría",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar"
|
||||||
"clearSort": "Limpiar Ordenamiento",
|
|
||||||
"sortAscending": "Ordenar Ascendente",
|
|
||||||
"sortDescending": "Ordenar Descendente",
|
|
||||||
"sortByField": "Ordenar por {{field}}",
|
|
||||||
"ascendingOrder": "Ascendente",
|
|
||||||
"descendingOrder": "Descendente",
|
|
||||||
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,12 +78,5 @@
|
|||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"enterStatusName": "Digite o nome do status",
|
"enterStatusName": "Digite o nome do status",
|
||||||
"selectCategory": "Selecionar categoria",
|
"selectCategory": "Selecionar categoria",
|
||||||
"close": "Fechar",
|
"close": "Fechar"
|
||||||
"clearSort": "Limpar Ordenação",
|
|
||||||
"sortAscending": "Ordenar Crescente",
|
|
||||||
"sortDescending": "Ordenar Decrescente",
|
|
||||||
"sortByField": "Ordenar por {{field}}",
|
|
||||||
"ascendingOrder": "Crescente",
|
|
||||||
"descendingOrder": "Decrescente",
|
|
||||||
"currentSort": "Ordenação atual: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,12 +75,5 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"enterStatusName": "输入状态名称",
|
"enterStatusName": "输入状态名称",
|
||||||
"selectCategory": "选择类别",
|
"selectCategory": "选择类别",
|
||||||
"close": "关闭",
|
"close": "关闭"
|
||||||
"clearSort": "清除排序",
|
|
||||||
"sortAscending": "升序排列",
|
|
||||||
"sortDescending": "降序排列",
|
|
||||||
"sortByField": "按{{field}}排序",
|
|
||||||
"ascendingOrder": "升序",
|
|
||||||
"descendingOrder": "降序",
|
|
||||||
"currentSort": "当前排序:{{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
@@ -51,16 +51,12 @@ import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
|||||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||||
import projectManagerApiRouter from "./project-managers-api-router";
|
import projectManagerApiRouter from "./project-managers-api-router";
|
||||||
import surveyApiRouter from "./survey-api-router";
|
|
||||||
|
|
||||||
import billingApiRouter from "./billing-api-router";
|
import billingApiRouter from "./billing-api-router";
|
||||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||||
|
|
||||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||||
|
|
||||||
|
|
||||||
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
|
|
||||||
import moderationApiRouter from "./moderation-api-router";
|
|
||||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||||
|
|
||||||
const api = express.Router();
|
const api = express.Router();
|
||||||
@@ -107,7 +103,6 @@ api.use("/roadmap-gannt", roadmapApiRouter);
|
|||||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||||
api.use("/schedule-gannt", scheduleApiRouter);
|
api.use("/schedule-gannt", scheduleApiRouter);
|
||||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||||
api.use("/surveys", surveyApiRouter);
|
|
||||||
api.use("/project-managers", projectManagerApiRouter);
|
api.use("/project-managers", projectManagerApiRouter);
|
||||||
|
|
||||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||||
@@ -122,6 +117,4 @@ api.use("/task-recurring", taskRecurringApiRouter);
|
|||||||
api.use("/task-recurring", taskRecurringApiRouter);
|
api.use("/task-recurring", taskRecurringApiRouter);
|
||||||
|
|
||||||
api.use("/custom-columns", customColumnsApiRouter);
|
api.use("/custom-columns", customColumnsApiRouter);
|
||||||
api.use("/logs", userActivityLogsApiRouter);
|
|
||||||
api.use("/moderation", moderationApiRouter);
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import SurveyController from "../../controllers/survey-controller";
|
|
||||||
import surveySubmissionValidator from "../../middlewares/validators/survey-submission-validator";
|
|
||||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
// Get user's survey response for a specific survey
|
|
||||||
surveyApiRouter.get("/responses/:survey_id", safeControllerFunction(SurveyController.getUserSurveyResponse));
|
|
||||||
|
|
||||||
export default surveyApiRouter;
|
|
||||||
@@ -6,7 +6,6 @@ import idParamValidator from "../../middlewares/validators/id-param-validator";
|
|||||||
import teamMembersBodyValidator from "../../middlewares/validators/team-members-body-validator";
|
import teamMembersBodyValidator from "../../middlewares/validators/team-members-body-validator";
|
||||||
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
|
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
|
||||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||||
import { RateLimiter } from "../../middleware/rate-limiter";
|
|
||||||
|
|
||||||
const teamMembersApiRouter = express.Router();
|
const teamMembersApiRouter = express.Router();
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ const teamMembersApiRouter = express.Router();
|
|||||||
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
|
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
|
||||||
teamMembersApiRouter.get("/export/:id", idParamValidator, safeControllerFunction(TeamMembersController.exportByMember));
|
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("/", safeControllerFunction(TeamMembersController.get));
|
||||||
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
|
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
|
||||||
teamMembersApiRouter.get("/tree-map", safeControllerFunction(TeamMembersController.getTeamMembersTreeMap));
|
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.delete("/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.deleteById));
|
||||||
teamMembersApiRouter.get("/deactivate/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.toggleMemberActiveStatus));
|
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;
|
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
|
// paddle status to exclude
|
||||||
export const statusExclude = ["past_due", "paused", "deleted"];
|
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 HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
|
||||||
|
|
||||||
export const UNMAPPED = "Unmapped";
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,3 @@ VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
|||||||
|
|
||||||
# Google Login
|
# Google Login
|
||||||
VITE_ENABLE_GOOGLE_LOGIN=false
|
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,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
|
# production
|
||||||
/build
|
/build
|
||||||
/public/tinymce
|
/public/tinymce
|
||||||
/docs
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.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",
|
"build": "vite build",
|
||||||
"dev-build": "vite build",
|
"dev-build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write ."
|
||||||
"test": "vitest",
|
|
||||||
"test:run": "vitest run",
|
|
||||||
"test:coverage": "vitest run --coverage",
|
|
||||||
"test:ui": "vitest --ui"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.1.0",
|
"@ant-design/colors": "^7.1.0",
|
||||||
@@ -81,10 +77,7 @@
|
|||||||
"@types/react-dom": "19.0.0",
|
"@types/react-dom": "19.0.0",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
|
||||||
"@vitest/ui": "^3.2.4",
|
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"jsdom": "^26.1.0",
|
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||||
"rollup": "^4.40.2",
|
"rollup": "^4.40.2",
|
||||||
|
|||||||
@@ -76,27 +76,40 @@ class HubSpotManager {
|
|||||||
style.id = this.styleId;
|
style.id = this.styleId;
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
/* HubSpot Chat Widget Dark Mode Override */
|
/* 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-inline-parent,
|
||||||
#hubspot-conversations-iframe-container {
|
#hubspot-conversations-iframe-container,
|
||||||
background: #141414 !important;
|
.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 */
|
/* Target HubSpot widget container backgrounds */
|
||||||
#hubspot-conversations-inline-parent div,
|
#hubspot-conversations-inline-parent div,
|
||||||
#hubspot-conversations-iframe-container div,
|
#hubspot-conversations-iframe-container div,
|
||||||
[data-test-id="chat-widget"] div {
|
[data-test-id="chat-widget"] div {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
/* Ensure Worklenz app elements are not affected by HubSpot styles */
|
|
||||||
.ant-menu,
|
/* Prevent double inversion of images, avatars, and icons */
|
||||||
.ant-menu *,
|
#hubspot-conversations-iframe-container img,
|
||||||
[class*="settings"],
|
#hubspot-conversations-iframe-container [style*="background-image"],
|
||||||
[class*="sidebar"],
|
#hubspot-conversations-iframe-container svg,
|
||||||
.worklenz-app *:not([id*="hubspot"]):not([class*="widget"]) {
|
iframe[src*="hubspot"] img,
|
||||||
filter: none !important;
|
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);
|
document.head.appendChild(style);
|
||||||
|
|||||||
@@ -1,194 +1,31 @@
|
|||||||
{
|
{
|
||||||
"continue": "Vazhdo",
|
"continue": "Vazhdo",
|
||||||
|
|
||||||
"setupYourAccount": "Konfiguro llogarinë tënde.",
|
"setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.",
|
||||||
"organizationStepTitle": "Emërto organizatën tënde",
|
"organizationStepTitle": "Emërtoni Organizatën Tuaj",
|
||||||
"organizationStepLabel": "Zgjidh një emër për llogarinë tënde në Worklenz.",
|
"organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
|
||||||
"organizationStepWelcome": "Konfiguro llogarinë tënde në Worklenz.",
|
|
||||||
"organizationStepDescription": "Le të fillojmë duke konfiguruar organizatën tënde. Kjo do të jetë hapësira kryesore e punës për ekipin tënd.",
|
"projectStepTitle": "Krijoni projektin tuaj të parë",
|
||||||
"organizationStepTooltip": "Ky emër do të shfaqet në hapësirën tënde të punës dhe mund të ndryshohet më vonë në cilësime.",
|
"projectStepLabel": "Në cilin projekt po punoni aktualisht?",
|
||||||
"organizationStepNeedIdeas": "Keni nevojë për ide?",
|
|
||||||
"organizationStepUseDetected": "Përdorimi i zbuluar:",
|
|
||||||
"organizationStepCharacters": "karaktere",
|
|
||||||
"organizationStepGoodLength": "Gjatësi e mirë",
|
|
||||||
"organizationStepTooShort": "Shumë i shkurtër",
|
|
||||||
"organizationStepNamingTips": "Këshilla për emërtimin",
|
|
||||||
"organizationStepTip1": "Mbaje të thjeshtë dhe të lehtë për t'u mbajtur mend",
|
|
||||||
"organizationStepTip2": "Përfaqëso industrinë ose vlerat e tua",
|
|
||||||
"organizationStepTip3": "Mendo për rritjen në të ardhmen",
|
|
||||||
"organizationStepTip4": "Bëje unik dhe të përshtatshëm për markë",
|
|
||||||
"organizationStepSuggestionsTitle": "Sugjerime për emra",
|
|
||||||
"organizationStepCategory1": "Kompani Teknologjie",
|
|
||||||
"organizationStepCategory2": "Agjenci Kreative",
|
|
||||||
"organizationStepCategory3": "Konsulencë",
|
|
||||||
"organizationStepCategory4": "Startupe",
|
|
||||||
"organizationStepSuggestionsNote": "Këto janë vetëm shembuj për të të ndihmuar të fillosh. Zgjidh diçka që përfaqëson organizatën tënde.",
|
|
||||||
"organizationStepPrivacyNote": "Emri i organizatës tënde është privat dhe i dukshëm vetëm për anëtarët e ekipit.",
|
|
||||||
"projectStepTitle": "Krijo projektin tënd të parë",
|
|
||||||
"projectStepLabel": "Në cilin projekt po punon tani?",
|
|
||||||
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
|
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
|
||||||
"tasksStepTitle": "Krijo detyrat e tua të para",
|
|
||||||
"tasksStepLabel": "Shkruaj disa detyra që do të kryesh në",
|
"tasksStepTitle": "Krijoni detyrat tuaja të para",
|
||||||
|
"tasksStepLabel": "Shkruani disa detyra që do të kryeni në",
|
||||||
"tasksStepAddAnother": "Shto një tjetër",
|
"tasksStepAddAnother": "Shto një tjetër",
|
||||||
"emailPlaceholder": "Adresa e emailit",
|
|
||||||
"invalidEmail": "Ju lutem vendosni një adresë emaili të vlefshme",
|
"emailPlaceholder": "Adresa email",
|
||||||
|
"invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme",
|
||||||
"or": "ose",
|
"or": "ose",
|
||||||
"templateButton": "Importo nga shablloni",
|
"templateButton": "Importo nga shablloni",
|
||||||
"goBack": "Kthehu mbrapa",
|
"goBack": "Kthehu Mbrapa",
|
||||||
"cancel": "Anulo",
|
"cancel": "Anulo",
|
||||||
"create": "Krijo",
|
"create": "Krijo",
|
||||||
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
||||||
"step3InputLabel": "Fto me email",
|
"step3InputLabel": "Fto me email",
|
||||||
"addAnother": "Shto një tjetër",
|
"addAnother": "Shto një tjetër",
|
||||||
"skipForNow": "Kalo për tani",
|
"skipForNow": "Kalo tani për tani",
|
||||||
"skipping": "Duke kaluar...",
|
"formTitle": "Krijoni detyrën tuaj të parë.",
|
||||||
"formTitle": "Krijo detyrën tënde të parë.",
|
"step3Title": "Fto ekipin tënd të punojë me",
|
||||||
"step3Title": "Fto ekipin tënd për të punuar së bashku",
|
|
||||||
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
|
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
|
||||||
"maxTasks": " (Mund të krijoni deri në 5 detyra)",
|
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
|
||||||
"membersStepTitle": "Fto ekipin tënd",
|
|
||||||
"membersStepDescription": "Shto anëtarë ekipi në \"{{organizationName}}\" dhe filloni bashkëpunimin",
|
|
||||||
"memberPlaceholder": "Anëtari i ekipit {{index}} - Shkruani adresën e emailit",
|
|
||||||
"validEmailAddress": "Adresë emaili e vlefshme",
|
|
||||||
"addAnotherTeamMember": "Shto një anëtar tjetër të ekipit ({{current}}/{{max}})",
|
|
||||||
"canInviteLater": "Gjithmonë mund të ftoni anëtarë të ekipit më vonë",
|
|
||||||
"skipStepDescription": "Nuk i keni adresat e emailit gati? Asnjë problem! Mund ta kaloni këtë hap dhe të ftoni anëtarë nga paneli i projektit më vonë.",
|
|
||||||
"orgCategoryTech": "Kompani Teknologjie",
|
|
||||||
"orgCategoryCreative": "Agjenci Kreative",
|
|
||||||
"orgCategoryConsulting": "Konsulencë",
|
|
||||||
"orgCategoryStartups": "Startupe",
|
|
||||||
"namingTip1": "Mbaje të thjeshtë dhe të lehtë për t'u mbajtur mend",
|
|
||||||
"namingTip2": "Përfaqëso industrinë ose vlerat e tua",
|
|
||||||
"namingTip3": "Mendo për rritjen në të ardhmen",
|
|
||||||
"namingTip4": "Bëje unik dhe të përshtatshëm për markë",
|
|
||||||
"aboutYouTitle": "Na trego për veten tënde",
|
|
||||||
"aboutYouDescription": "Na ndihmo të personalizojmë përvojën tënde",
|
|
||||||
"orgTypeQuestion": "Cila përshkruan më mirë organizatën tënde?",
|
|
||||||
"userRoleQuestion": "Cili është roli yt?",
|
|
||||||
"yourNeedsTitle": "Cilat janë nevojat e tua kryesore?",
|
|
||||||
"yourNeedsDescription": "Zgjidh të gjitha që aplikohen për të na ndihmuar të konfigurojmë hapësirën tënde të punës",
|
|
||||||
"yourNeedsQuestion": "Si do ta përdorësh kryesisht Worklenz?",
|
|
||||||
"useCaseTaskOrg": "Organizo dhe ndiq detyrat",
|
|
||||||
"useCaseTeamCollab": "Puno së bashku pa pengesa",
|
|
||||||
"useCaseResourceMgmt": "Menaxho kohën dhe burimet",
|
|
||||||
"useCaseClientComm": "Qëndro i lidhur me klientët",
|
|
||||||
"useCaseTimeTrack": "Monitoro orët e projektit",
|
|
||||||
"useCaseOther": "Diçka tjetër",
|
|
||||||
"selectedText": "zgjedhur",
|
|
||||||
"previousToolsQuestion": "Çfarë mjetesh ke përdorur më parë? (Opsionale)",
|
|
||||||
"discoveryTitle": "Edhe një gjë e fundit...",
|
|
||||||
"discoveryDescription": "Na ndihmo të kuptojmë si e zbulove Worklenz",
|
|
||||||
"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",
|
|
||||||
"stepProgress": "Hapi {step} nga 3: {title}",
|
|
||||||
"projectStepHeader": "Le të krijojmë projektin tënd të parë",
|
|
||||||
"projectStepSubheader": "Fillo nga e para ose përdor një shabllon për të filluar më shpejt",
|
|
||||||
"startFromScratch": "Fillo nga e para",
|
|
||||||
"templateSelected": "Shablloni i zgjedhur më poshtë",
|
|
||||||
"quickSuggestions": "Sugjerime të shpejta:",
|
|
||||||
"orText": "OSE",
|
|
||||||
"startWithTemplate": "Fillo me një shabllon",
|
|
||||||
"clearToSelectTemplate": "Pastro emrin e projektit më sipër për të zgjedhur një shabllon",
|
|
||||||
"templateHeadStart": "Fillo më shpejt me struktura të gatshme projekti",
|
|
||||||
"browseAllTemplates": "Shfleto të gjitha shabllonet",
|
|
||||||
"templatesAvailable": "15+ shabllone të specializuara sipas industrisë në dispozicion",
|
|
||||||
"chooseTemplate": "Zgjidh një shabllon që i përshtatet llojit të projektit tënd",
|
|
||||||
"createProject": "Krijo projekt",
|
|
||||||
"templateSoftwareDev": "Zhvillim Softueri",
|
|
||||||
"templateSoftwareDesc": "Sprint-e agile, ndjekje gabimesh, lëshime",
|
|
||||||
"templateMarketing": "Fushatë Marketingu",
|
|
||||||
"templateMarketingDesc": "Planifikim fushate, kalendar përmbajtjesh",
|
|
||||||
"templateConstruction": "Projekt Ndërtimi",
|
|
||||||
"templateConstructionDesc": "Faza, leje, kontraktorë",
|
|
||||||
"templateStartup": "Lansim Startup-i",
|
|
||||||
"templateStartupDesc": "Zhvillim MVP, financim, rritje",
|
|
||||||
"tasksStepDescription": "Ndaji \"{{projectName}}\" në detyra të veprueshme për të filluar",
|
|
||||||
"taskPlaceholder": "Detyra {{index}} - p.sh., Çfarë duhet bërë?",
|
|
||||||
"addAnotherTask": "Shto një detyrë tjetër ({{current}}/{{max}})",
|
|
||||||
"surveyStepTitle": "Na trego për veten tënde",
|
|
||||||
"surveyStepLabel": "Na ndihmo të personalizojmë përvojën tënde në Worklenz duke iu përgjigjur disa pyetjeve.",
|
|
||||||
"organizationType": "Cila përshkruan më mirë organizatën tënde?",
|
|
||||||
"organizationTypeFreelancer": "Freelancer",
|
|
||||||
"organizationTypeStartup": "Startup",
|
|
||||||
"organizationTypeSmallMediumBusiness": "Biznes i Vogël ose i Mesëm",
|
|
||||||
"organizationTypeAgency": "Agjenci",
|
|
||||||
"organizationTypeEnterprise": "Ndërmarrje",
|
|
||||||
"organizationTypeOther": "Tjetër",
|
|
||||||
"userRole": "Cili është roli yt?",
|
|
||||||
"userRoleFounderCeo": "Themelues / CEO",
|
|
||||||
"userRoleProjectManager": "Menaxher Projekti",
|
|
||||||
"userRoleSoftwareDeveloper": "Zhvillues Softueri",
|
|
||||||
"userRoleDesigner": "Dizajner",
|
|
||||||
"userRoleOperations": "Operacionet",
|
|
||||||
"userRoleOther": "Tjetër",
|
|
||||||
"mainUseCases": "Për çfarë do ta përdorësh kryesisht Worklenz?",
|
|
||||||
"mainUseCasesTaskManagement": "Menaxhim detyrash",
|
|
||||||
"mainUseCasesTeamCollaboration": "Bashkëpunim ekipi",
|
|
||||||
"mainUseCasesResourcePlanning": "Planifikim burimesh",
|
|
||||||
"mainUseCasesClientCommunication": "Komunikim & raportim me klientët",
|
|
||||||
"mainUseCasesTimeTracking": "Ndjekje kohe",
|
|
||||||
"mainUseCasesOther": "Tjetër",
|
|
||||||
"previousTools": "Çfarë mjetesh ke përdorur para Worklenz?",
|
|
||||||
"previousToolsPlaceholder": "p.sh. Trello, Asana, Monday.com",
|
|
||||||
"howHeardAbout": "Si dëgjove për Worklenz?",
|
|
||||||
"howHeardAboutGoogleSearch": "Kërkim në Google",
|
|
||||||
"howHeardAboutTwitter": "Twitter",
|
|
||||||
"howHeardAboutLinkedin": "LinkedIn",
|
|
||||||
"howHeardAboutFriendColleague": "Një mik ose koleg",
|
|
||||||
"howHeardAboutBlogArticle": "Një blog ose artikull",
|
|
||||||
"howHeardAboutOther": "Tjetër",
|
|
||||||
|
|
||||||
"aboutYouStepTitle": "Na trego për veten",
|
|
||||||
"aboutYouStepDescription": "Na ndihmo të personalizojmë përvojën tënde",
|
|
||||||
"yourNeedsStepTitle": "Cilat janë nevojat e tua kryesore?",
|
|
||||||
"yourNeedsStepDescription": "Zgjidh të gjitha që aplikohen për të na ndihmuar të konfigurojmë hapësirën tënde të punës",
|
|
||||||
"selected": "zgjedhur",
|
|
||||||
"previousToolsLabel": "Çfarë mjetesh ke përdorur më parë? (Opsionale)",
|
|
||||||
|
|
||||||
"roleSuggestions": {
|
|
||||||
"designer": "UI/UX, Grafikë, Kreativ",
|
|
||||||
"developer": "Frontend, Backend, Full-stack",
|
|
||||||
"projectManager": "Planifikim, Koordinim",
|
|
||||||
"marketing": "Përmbajtje, Media Sociale, Rritje",
|
|
||||||
"sales": "Zhvillim Biznesi, Marrëdhënie me Klientë",
|
|
||||||
"operations": "Administratë, HR, Financa"
|
|
||||||
},
|
|
||||||
|
|
||||||
"languages": {
|
|
||||||
"en": "Anglisht",
|
|
||||||
"es": "Spanjisht",
|
|
||||||
"pt": "Portugalisht",
|
|
||||||
"de": "Gjermanisht",
|
|
||||||
"alb": "Shqip",
|
|
||||||
"zh": "Kinezçe"
|
|
||||||
},
|
|
||||||
|
|
||||||
"orgSuggestions": {
|
|
||||||
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
|
||||||
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
|
||||||
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
|
||||||
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"projectSuggestions": {
|
|
||||||
"freelancer": ["Projekti i Klientit", "Përditësim Portfolio", "Markë Personale"],
|
|
||||||
"startup": ["Zhvillim MVP", "Lansim Produkti", "Kërkim Tregu"],
|
|
||||||
"agency": ["Fushatë Klienti", "Strategji Markë", "Ridizajnim Website"],
|
|
||||||
"enterprise": ["Migrim Sistemi", "Optimizim Procesesh", "Trajnim Ekipi"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"useCaseDescriptions": {
|
|
||||||
"taskManagement": "Organizoj dhe ndjek detyrat",
|
|
||||||
"teamCollaboration": "Punojmë së bashku pa probleme",
|
|
||||||
"resourcePlanning": "Menaxhoj kohën dhe burimet",
|
|
||||||
"clientCommunication": "Qëndroj i lidhur me klientët",
|
|
||||||
"timeTracking": "Monitoroj orët e projektit",
|
|
||||||
"other": "Diçka tjetër"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"reconnecting": "Jeni shkëputur nga serveri.",
|
||||||
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
||||||
"connection-restored": "U lidhët me serverin me sukses",
|
"connection-restored": "U lidhët me serverin me sukses"
|
||||||
"cancel": "Anulo"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,22 +41,6 @@
|
|||||||
"list": "Listë",
|
"list": "Listë",
|
||||||
"calendar": "Kalendar",
|
"calendar": "Kalendar",
|
||||||
"tasks": "Detyrat",
|
"tasks": "Detyrat",
|
||||||
"refresh": "Rifresko",
|
"refresh": "Rifresko"
|
||||||
"recentActivity": "Aktiviteti i Fundit",
|
|
||||||
"recentTasks": "Detyrat e Fundit",
|
|
||||||
"recentTasksSegment": "Detyrat e Fundit",
|
|
||||||
"timeLogged": "Koha e Regjistruar",
|
|
||||||
"timeLoggedSegment": "Koha e Regjistruar",
|
|
||||||
"noRecentTasks": "Asnjë detyrë e fundit",
|
|
||||||
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
|
|
||||||
"activityTag": "Aktiviteti",
|
|
||||||
"timeLogTag": "Regjistrim Kohe",
|
|
||||||
"timerTag": "Kohëmatës",
|
|
||||||
"activitySingular": "aktivitet",
|
|
||||||
"activityPlural": "aktivitete",
|
|
||||||
"recentTaskAriaLabel": "Detyrë e fundit:",
|
|
||||||
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:",
|
|
||||||
"errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit",
|
|
||||||
"errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,6 @@
|
|||||||
"deleteConfirmationOk": "Po",
|
"deleteConfirmationOk": "Po",
|
||||||
"deleteConfirmationCancel": "Anulo",
|
"deleteConfirmationCancel": "Anulo",
|
||||||
|
|
||||||
"deleteTaskTitle": "Fshi Detyrën",
|
|
||||||
"deleteTaskContent": "Jeni i sigurt që doni të fshini këtë detyrë? Kjo veprim nuk mund të zhbëhet.",
|
|
||||||
"deleteTaskConfirm": "Fshi",
|
|
||||||
"deleteTaskCancel": "Anulo",
|
|
||||||
|
|
||||||
"deleteStatusTitle": "Fshi Statusin",
|
|
||||||
"deleteStatusContent": "Jeni i sigurt që doni të fshini këtë status? Kjo veprim nuk mund të zhbëhet.",
|
|
||||||
|
|
||||||
"deletePhaseTitle": "Fshi Fazen",
|
|
||||||
"deletePhaseContent": "Jeni i sigurt që doni të fshini këtë fazë? Kjo veprim nuk mund të zhbëhet.",
|
|
||||||
|
|
||||||
"dueDate": "Data e përfundimit",
|
"dueDate": "Data e përfundimit",
|
||||||
"cancel": "Anulo",
|
"cancel": "Anulo",
|
||||||
|
|
||||||
@@ -37,17 +26,5 @@
|
|||||||
"noDueDate": "Pa datë përfundimi",
|
"noDueDate": "Pa datë përfundimi",
|
||||||
"save": "Ruaj",
|
"save": "Ruaj",
|
||||||
"clear": "Pastro",
|
"clear": "Pastro",
|
||||||
"nextWeek": "Javën e ardhshme",
|
"nextWeek": "Javën e ardhshme"
|
||||||
"noSubtasks": "Pa nëndetyra",
|
|
||||||
"showSubtasks": "Shfaq nëndetyrat",
|
|
||||||
"hideSubtasks": "Fshih nëndetyrat",
|
|
||||||
|
|
||||||
"errorLoadingTasks": "Gabim gjatë ngarkimit të detyrave",
|
|
||||||
"noTasksFound": "Nuk u gjetën detyra",
|
|
||||||
"loadingFilters": "Duke ngarkuar filtra...",
|
|
||||||
"failedToUpdateColumnOrder": "Dështoi përditësimi i rendit të kolonave",
|
|
||||||
"failedToUpdatePhaseOrder": "Dështoi përditësimi i rendit të fazave",
|
|
||||||
"pleaseTryAgain": "Ju lutemi provoni përsëri",
|
|
||||||
"taskNotCompleted": "Detyra nuk është përfunduar",
|
|
||||||
"completeTaskDependencies": "Ju lutemi përfundoni varësitë e detyrës para se të vazhdoni"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,5 @@
|
|||||||
"deleteButtonTooltip": "Hiq nga projekti",
|
"deleteButtonTooltip": "Hiq nga projekti",
|
||||||
"memberCount": "Anëtar",
|
"memberCount": "Anëtar",
|
||||||
"membersCountPlural": "Anëtarë",
|
"membersCountPlural": "Anëtarë",
|
||||||
"emptyText": "Nuk ka bashkëngjitje në projekt.",
|
"emptyText": "Nuk ka bashkëngjitje në projekt."
|
||||||
"searchPlaceholder": "Kërko anëtarë"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,5 @@
|
|||||||
"searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre",
|
"searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre",
|
||||||
"searchPlaceholder": "Shkruani emrin ose email-in",
|
"searchPlaceholder": "Shkruani emrin ose email-in",
|
||||||
"inviteAsAMember": "Fto si anëtar",
|
"inviteAsAMember": "Fto si anëtar",
|
||||||
"inviteNewMemberByEmail": "Fto anëtar të ri me email",
|
"inviteNewMemberByEmail": "Fto anëtar të ri me email"
|
||||||
"members": "Anëtarë",
|
|
||||||
"copyProjectLink": "Kopjo lidhjen e projektit",
|
|
||||||
"inviteMember": "Fto anëtar",
|
|
||||||
"alsoInviteToProject": "Fto edhe në projekt"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Titulli i Punës",
|
"jobTitleLabel": "Titulli i Punës",
|
||||||
"jobTitlePlaceholder": "Zgjidh ose kërko titull pune (Opsionale)",
|
"jobTitlePlaceholder": "Zgjidh ose kërko titull pune (Opsionale)",
|
||||||
"memberAccessLabel": "Niveli i Qasjes",
|
"memberAccessLabel": "Niveli i Qasjes",
|
||||||
"addToTeamButton": "Dërgo ftesën",
|
"addToTeamButton": "Shto Anëtar në Ekip",
|
||||||
"updateButton": "Ruaj Ndryshimet",
|
"updateButton": "Ruaj Ndryshimet",
|
||||||
"resendInvitationButton": "Dërgo Përsëri Email-in e Ftesës",
|
"resendInvitationButton": "Dërgo Përsëri Email-in e Ftesës",
|
||||||
"invitationSentSuccessMessage": "Ftesa për ekip u dërgua me sukses!",
|
"invitationSentSuccessMessage": "Ftesa për ekip u dërgua me sukses!",
|
||||||
@@ -43,6 +43,5 @@
|
|||||||
"updatedText": "Përditësuar",
|
"updatedText": "Përditësuar",
|
||||||
"noResultFound": "Shkruani një adresë email dhe shtypni Enter...",
|
"noResultFound": "Shkruani një adresë email dhe shtypni Enter...",
|
||||||
"jobTitlesFetchError": "Dështoi marrja e titujve të punës",
|
"jobTitlesFetchError": "Dështoi marrja e titujve të punës",
|
||||||
"invitationResent": "Ftesa u dërgua sërish me sukses!",
|
"invitationResent": "Ftesa u dërgua sërish me sukses!"
|
||||||
"copyTeamLink": "Kopjo lidhjen e ekipit"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"close": "Mbyll",
|
||||||
"cannotMoveStatus": "Nuk mund të lëvizet statusi",
|
"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.",
|
"cannotMoveStatusMessage": "Nuk mund të lëvizet ky status sepse do të linte kategorinë '{{categoryName}}' bosh. Çdo kategori duhet të ketë të paktën një status.",
|
||||||
"ok": "OK",
|
"ok": "OK"
|
||||||
"clearSort": "Pastro Renditjen",
|
|
||||||
"sortAscending": "Rendit në Rritje",
|
|
||||||
"sortDescending": "Rendit në Zbritje",
|
|
||||||
"sortByField": "Rendit sipas {{field}}",
|
|
||||||
"ascendingOrder": "Rritës",
|
|
||||||
"descendingOrder": "Zbritës",
|
|
||||||
"currentSort": "Renditja aktuale: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,6 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Cakto mua",
|
"assignToMe": "Cakto mua",
|
||||||
"copyLink": "Kopjo lidhjen e detyrës",
|
|
||||||
"linkCopied": "Lidhja u kopjua në clipboard",
|
|
||||||
"linkCopyFailed": "Dështoi kopjimi i lidhjes",
|
|
||||||
"moveTo": "Zhvendos në",
|
"moveTo": "Zhvendos në",
|
||||||
"unarchive": "Ç'arkivizo",
|
"unarchive": "Ç'arkivizo",
|
||||||
"archive": "Arkivizo",
|
"archive": "Arkivizo",
|
||||||
@@ -136,11 +133,5 @@
|
|||||||
"dependencies": "Detyra ka varësi",
|
"dependencies": "Detyra ka varësi",
|
||||||
"recurring": "Detyrë përsëritëse"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,7 @@
|
|||||||
|
|
||||||
"setupYourAccount": "Richten Sie Ihr Worklenz-Konto ein.",
|
"setupYourAccount": "Richten Sie Ihr Worklenz-Konto ein.",
|
||||||
"organizationStepTitle": "Organisation benennen",
|
"organizationStepTitle": "Organisation benennen",
|
||||||
"organizationStepWelcome": "Willkommen bei Worklenz!",
|
"organizationStepLabel": "Wählen Sie einen Namen für Ihr Worklenz-Konto.",
|
||||||
"organizationStepDescription": "Beginnen wir mit der Einrichtung Ihrer Organisation. Dies wird der Hauptarbeitsplatz für Ihr Team.",
|
|
||||||
"organizationStepLabel": "Organisationsname",
|
|
||||||
"organizationStepPlaceholder": "z.B. Acme Corporation",
|
|
||||||
"organizationStepTooltip": "Dieser Name wird in Ihrem Arbeitsbereich angezeigt und kann später in den Einstellungen geändert werden.",
|
|
||||||
"organizationStepNeedIdeas": "Brauchen Sie Ideen?",
|
|
||||||
"organizationStepUseDetected": "Erkannt verwenden:",
|
|
||||||
"organizationStepCharacters": "Zeichen",
|
|
||||||
"organizationStepGoodLength": "Gute Länge",
|
|
||||||
"organizationStepTooShort": "Zu kurz",
|
|
||||||
"organizationStepNamingTips": "Namensgebungstipps",
|
|
||||||
"organizationStepTip1": "Halten Sie es einfach und einprägsam",
|
|
||||||
"organizationStepTip2": "Spiegeln Sie Ihre Branche oder Werte wider",
|
|
||||||
"organizationStepTip3": "Denken Sie an zukünftiges Wachstum",
|
|
||||||
"organizationStepTip4": "Machen Sie es einzigartig und markenfähig",
|
|
||||||
"organizationStepSuggestionsTitle": "Namensvorschläge",
|
|
||||||
"organizationStepCategory1": "Tech-Unternehmen",
|
|
||||||
"organizationStepCategory2": "Kreativagenturen",
|
|
||||||
"organizationStepCategory3": "Beratung",
|
|
||||||
"organizationStepCategory4": "Startups",
|
|
||||||
"organizationStepSuggestionsNote": "Dies sind nur Beispiele für den Einstieg. Wählen Sie etwas, das Ihre Organisation repräsentiert.",
|
|
||||||
"organizationStepPrivacyNote": "Ihr Organisationsname ist privat und nur für Ihre Teammitglieder sichtbar.",
|
|
||||||
|
|
||||||
"projectStepTitle": "Erstellen Sie Ihr erstes Projekt",
|
"projectStepTitle": "Erstellen Sie Ihr erstes Projekt",
|
||||||
"projectStepLabel": "An welchem Projekt arbeiten Sie gerade?",
|
"projectStepLabel": "An welchem Projekt arbeiten Sie gerade?",
|
||||||
@@ -45,170 +24,8 @@
|
|||||||
"step3InputLabel": "Per E-Mail einladen",
|
"step3InputLabel": "Per E-Mail einladen",
|
||||||
"addAnother": "Weitere hinzufügen",
|
"addAnother": "Weitere hinzufügen",
|
||||||
"skipForNow": "Jetzt überspringen",
|
"skipForNow": "Jetzt überspringen",
|
||||||
"skipping": "Überspringen...",
|
|
||||||
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
|
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
|
||||||
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
|
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
|
||||||
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
|
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
|
||||||
"maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)",
|
"maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)"
|
||||||
|
|
||||||
"membersStepTitle": "Laden Sie Ihr Team ein",
|
|
||||||
"membersStepDescription": "Teammitglieder zu \"{{organizationName}}\" hinzufügen und mit der Zusammenarbeit beginnen",
|
|
||||||
"memberPlaceholder": "Teammitglied {{index}} - E-Mail-Adresse eingeben",
|
|
||||||
"validEmailAddress": "Gültige E-Mail-Adresse",
|
|
||||||
"addAnotherTeamMember": "Weiteres Teammitglied hinzufügen ({{current}}/{{max}})",
|
|
||||||
"canInviteLater": "Sie können Teammitglieder jederzeit später einladen",
|
|
||||||
"skipStepDescription": "Haben Sie keine E-Mail-Adressen bereit? Kein Problem! Sie können diesen Schritt überspringen und Teammitglieder später über Ihr Projekt-Dashboard einladen.",
|
|
||||||
|
|
||||||
"orgCategoryTech": "Technologieunternehmen",
|
|
||||||
"orgCategoryCreative": "Kreativagenturen",
|
|
||||||
"orgCategoryConsulting": "Beratung",
|
|
||||||
"orgCategoryStartups": "Startups",
|
|
||||||
"namingTip1": "Halten Sie es einfach und einprägsam",
|
|
||||||
"namingTip2": "Spiegeln Sie Ihre Branche oder Werte wider",
|
|
||||||
"namingTip3": "Denken Sie an zukünftiges Wachstum",
|
|
||||||
"namingTip4": "Machen Sie es einzigartig und markenfähig",
|
|
||||||
|
|
||||||
"aboutYouTitle": "Erzählen Sie uns von sich",
|
|
||||||
"aboutYouDescription": "Helfen Sie uns, Ihre Erfahrung zu personalisieren",
|
|
||||||
"orgTypeQuestion": "Was beschreibt Ihre Organisation am besten?",
|
|
||||||
"userRoleQuestion": "Was ist Ihre Rolle?",
|
|
||||||
|
|
||||||
"yourNeedsTitle": "Was sind Ihre Hauptbedürfnisse?",
|
|
||||||
"yourNeedsDescription": "Wählen Sie alle zutreffenden aus, um uns bei der Einrichtung Ihres Arbeitsbereichs zu helfen",
|
|
||||||
"yourNeedsQuestion": "Wie werden Sie Worklenz hauptsächlich nutzen?",
|
|
||||||
"useCaseTaskOrg": "Aufgaben organisieren und verfolgen",
|
|
||||||
"useCaseTeamCollab": "Nahtlos zusammenarbeiten",
|
|
||||||
"useCaseResourceMgmt": "Zeit und Ressourcen verwalten",
|
|
||||||
"useCaseClientComm": "Mit Kunden in Verbindung bleiben",
|
|
||||||
"useCaseTimeTrack": "Projektstunden überwachen",
|
|
||||||
"useCaseOther": "Etwas anderes",
|
|
||||||
"selectedText": "ausgewählt",
|
|
||||||
"previousToolsQuestion": "Welche Tools haben Sie zuvor verwendet? (Optional)",
|
|
||||||
"previousToolsPlaceholder": "z.B. Asana, Trello, Jira, Monday.com, etc.",
|
|
||||||
|
|
||||||
"discoveryTitle": "Eine letzte Sache...",
|
|
||||||
"discoveryDescription": "Helfen Sie uns zu verstehen, wie Sie Worklenz entdeckt haben",
|
|
||||||
"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",
|
|
||||||
"stepProgress": "Schritt {step} von 3: {title}",
|
|
||||||
|
|
||||||
"projectStepHeader": "Lassen Sie uns Ihr erstes Projekt erstellen",
|
|
||||||
"projectStepSubheader": "Von Grund auf beginnen oder eine Vorlage verwenden, um schneller voranzukommen",
|
|
||||||
"startFromScratch": "Von Grund auf beginnen",
|
|
||||||
"templateSelected": "Vorlage unten ausgewählt",
|
|
||||||
"quickSuggestions": "Schnelle Vorschläge:",
|
|
||||||
"orText": "ODER",
|
|
||||||
"startWithTemplate": "Mit einer Vorlage beginnen",
|
|
||||||
"clearToSelectTemplate": "Projektname oben löschen, um eine Vorlage auszuwählen",
|
|
||||||
"templateHeadStart": "Verschaffen Sie sich einen Vorsprung mit vorgefertigten Projektstrukturen",
|
|
||||||
"browseAllTemplates": "Alle Vorlagen durchsuchen",
|
|
||||||
"templatesAvailable": "15+ branchenspezifische Vorlagen verfügbar",
|
|
||||||
"chooseTemplate": "Wählen Sie eine Vorlage, die zu Ihrem Projekttyp passt",
|
|
||||||
"createProject": "Projekt erstellen",
|
|
||||||
|
|
||||||
"templateSoftwareDev": "Softwareentwicklung",
|
|
||||||
"templateSoftwareDesc": "Agile Sprints, Fehlerverfolgung, Releases",
|
|
||||||
"templateMarketing": "Marketing-Kampagne",
|
|
||||||
"templateMarketingDesc": "Kampagnenplanung, Content-Kalender",
|
|
||||||
"templateConstruction": "Bauprojekt",
|
|
||||||
"templateConstructionDesc": "Phasen, Genehmigungen, Auftragnehmer",
|
|
||||||
"templateStartup": "Startup-Launch",
|
|
||||||
"templateStartupDesc": "MVP-Entwicklung, Finanzierung, Wachstum",
|
|
||||||
|
|
||||||
"tasksStepTitle": "Fügen Sie Ihre ersten Aufgaben hinzu",
|
|
||||||
"tasksStepDescription": "Unterteilen Sie \"{{projectName}}\" in umsetzbare Aufgaben, um zu beginnen",
|
|
||||||
"taskPlaceholder": "Aufgabe {{index}} - z.B., Was muss getan werden?",
|
|
||||||
"addAnotherTask": "Weitere Aufgabe hinzufügen ({{current}}/{{max}})",
|
|
||||||
|
|
||||||
"surveyStepTitle": "Erzählen Sie uns von sich",
|
|
||||||
"surveyStepLabel": "Helfen Sie uns, Ihre Worklenz-Erfahrung zu personalisieren, indem Sie ein paar Fragen beantworten.",
|
|
||||||
|
|
||||||
"organizationType": "Was beschreibt Ihre Organisation am besten?",
|
|
||||||
"organizationTypeFreelancer": "Freelancer",
|
|
||||||
"organizationTypeStartup": "Startup",
|
|
||||||
"organizationTypeSmallMediumBusiness": "Kleines oder mittleres Unternehmen",
|
|
||||||
"organizationTypeAgency": "Agentur",
|
|
||||||
"organizationTypeEnterprise": "Unternehmen",
|
|
||||||
"organizationTypeOther": "Andere",
|
|
||||||
|
|
||||||
"userRole": "Was ist Ihre Rolle?",
|
|
||||||
"userRoleFounderCeo": "Gründer / CEO",
|
|
||||||
"userRoleProjectManager": "Projektmanager",
|
|
||||||
"userRoleSoftwareDeveloper": "Software-Entwickler",
|
|
||||||
"userRoleDesigner": "Designer",
|
|
||||||
"userRoleOperations": "Betrieb",
|
|
||||||
"userRoleOther": "Andere",
|
|
||||||
|
|
||||||
"mainUseCases": "Wofür werden Sie Worklenz hauptsächlich verwenden?",
|
|
||||||
"mainUseCasesTaskManagement": "Aufgabenverwaltung",
|
|
||||||
"mainUseCasesTeamCollaboration": "Teamzusammenarbeit",
|
|
||||||
"mainUseCasesResourcePlanning": "Ressourcenplanung",
|
|
||||||
"mainUseCasesClientCommunication": "Kundenkommunikation & Berichterstattung",
|
|
||||||
"mainUseCasesTimeTracking": "Zeiterfassung",
|
|
||||||
"mainUseCasesOther": "Andere",
|
|
||||||
|
|
||||||
"previousTools": "Welche Tools haben Sie vor Worklenz verwendet?",
|
|
||||||
"previousToolsPlaceholder": "z.B. Trello, Asana, Monday.com",
|
|
||||||
|
|
||||||
"howHeardAbout": "Wie haben Sie von Worklenz erfahren?",
|
|
||||||
"howHeardAboutGoogleSearch": "Google-Suche",
|
|
||||||
"howHeardAboutTwitter": "Twitter",
|
|
||||||
"howHeardAboutLinkedin": "LinkedIn",
|
|
||||||
"howHeardAboutFriendColleague": "Ein Freund oder Kollege",
|
|
||||||
"howHeardAboutBlogArticle": "Ein Blog oder Artikel",
|
|
||||||
"howHeardAboutOther": "Andere",
|
|
||||||
|
|
||||||
"aboutYouStepTitle": "Erzählen Sie uns von sich",
|
|
||||||
"aboutYouStepDescription": "Helfen Sie uns, Ihre Erfahrung zu personalisieren",
|
|
||||||
"yourNeedsStepTitle": "Was sind Ihre Hauptbedürfnisse?",
|
|
||||||
"yourNeedsStepDescription": "Wählen Sie alle zutreffenden aus, um uns bei der Einrichtung Ihres Arbeitsbereichs zu helfen",
|
|
||||||
"selected": "ausgewählt",
|
|
||||||
"previousToolsLabel": "Welche Tools haben Sie zuvor verwendet? (Optional)",
|
|
||||||
|
|
||||||
"roleSuggestions": {
|
|
||||||
"designer": "UI/UX, Grafiken, Kreativ",
|
|
||||||
"developer": "Frontend, Backend, Full-stack",
|
|
||||||
"projectManager": "Planung, Koordination",
|
|
||||||
"marketing": "Inhalt, Social Media, Wachstum",
|
|
||||||
"sales": "Geschäftsentwicklung, Kundenbeziehungen",
|
|
||||||
"operations": "Admin, HR, Finanzen"
|
|
||||||
},
|
|
||||||
|
|
||||||
"languages": {
|
|
||||||
"en": "English",
|
|
||||||
"es": "Español",
|
|
||||||
"pt": "Português",
|
|
||||||
"de": "Deutsch",
|
|
||||||
"alb": "Shqip",
|
|
||||||
"zh": "简体中文"
|
|
||||||
},
|
|
||||||
|
|
||||||
"orgSuggestions": {
|
|
||||||
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
|
||||||
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
|
||||||
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
|
||||||
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"projectSuggestions": {
|
|
||||||
"freelancer": ["Kundenprojekt", "Portfolio-Update", "Persönliche Marke"],
|
|
||||||
"startup": ["MVP-Entwicklung", "Produktlaunch", "Marktforschung"],
|
|
||||||
"agency": ["Kundenkampagne", "Markenstrategie", "Website-Redesign"],
|
|
||||||
"enterprise": ["Systemumstellung", "Prozessoptimierung", "Teamschulung"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"useCaseDescriptions": {
|
|
||||||
"taskManagement": "Aufgaben organisieren und verfolgen",
|
|
||||||
"teamCollaboration": "Nahtlos zusammenarbeiten",
|
|
||||||
"resourcePlanning": "Zeit und Ressourcen verwalten",
|
|
||||||
"clientCommunication": "Mit Kunden in Verbindung bleiben",
|
|
||||||
"timeTracking": "Projektstunden überwachen",
|
|
||||||
"other": "Etwas anderes"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"billingDetails": "Abrechnungsdetails",
|
|
||||||
"name": "Name",
|
|
||||||
"namePlaceholder": "Name",
|
|
||||||
"emailAddress": "E-Mail-Adresse",
|
|
||||||
"emailPlaceholder": "E-Mail-Adresse",
|
|
||||||
"contactNumber": "Telefonnummer",
|
|
||||||
"phoneNumberPlaceholder": "Telefonnummer",
|
|
||||||
"phoneValidationError": "Telefonnummer muss genau 10 Ziffern haben",
|
|
||||||
"companyDetails": "Firmendetails",
|
|
||||||
"companyName": "Firmenname",
|
|
||||||
"companyNamePlaceholder": "Firmenname",
|
|
||||||
"addressLine01": "Adresszeile 01",
|
|
||||||
"addressLine01Placeholder": "Adresszeile 01",
|
|
||||||
"addressLine02": "Adresszeile 02",
|
|
||||||
"addressLine02Placeholder": "Adresszeile 02",
|
|
||||||
"country": "Land",
|
|
||||||
"countryPlaceholder": "Land",
|
|
||||||
"city": "Stadt",
|
|
||||||
"cityPlaceholder": "Stadt",
|
|
||||||
"state": "Bundesland",
|
|
||||||
"statePlaceholder": "Bundesland",
|
|
||||||
"postalCode": "Postleitzahl",
|
|
||||||
"postalCodePlaceholder": "Postleitzahl",
|
|
||||||
"save": "Speichern"
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,12 @@
|
|||||||
"emailLabel": "E-Mail",
|
"emailLabel": "E-Mail",
|
||||||
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
|
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
|
||||||
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
|
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
|
||||||
"passwordLabel": "Passwort",
|
"passwordLabel": "Password",
|
||||||
"passwordGuideline": "Das Passwort muss mindestens 8 Zeichen lang sein und Groß- und Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten.",
|
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
||||||
"passwordPlaceholder": "Geben Sie Ihr Passwort ein",
|
"passwordPlaceholder": "Enter your password",
|
||||||
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
|
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
|
||||||
"passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!",
|
"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!",
|
"passwordPatternRequired": "Das Passwort entspricht nicht den Anforderungen!",
|
||||||
"strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben",
|
"strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben",
|
||||||
"passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.",
|
"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.",
|
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
|
||||||
"reconnecting": "Vom Server getrennt.",
|
"reconnecting": "Vom Server getrennt.",
|
||||||
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||||
"connection-restored": "Erfolgreich mit dem Server verbunden",
|
"connection-restored": "Erfolgreich mit dem Server verbunden"
|
||||||
"cancel": "Abbrechen"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,22 +41,6 @@
|
|||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren"
|
||||||
"recentActivity": "Aktuelle Aktivitäten",
|
|
||||||
"recentTasks": "Aktuelle Aufgaben",
|
|
||||||
"recentTasksSegment": "Aktuelle Aufgaben",
|
|
||||||
"timeLogged": "Erfasste Zeit",
|
|
||||||
"timeLoggedSegment": "Erfasste Zeit",
|
|
||||||
"noRecentTasks": "Keine aktuellen Aufgaben",
|
|
||||||
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
|
|
||||||
"activityTag": "Aktivität",
|
|
||||||
"timeLogTag": "Zeiterfassung",
|
|
||||||
"timerTag": "Timer",
|
|
||||||
"activitySingular": "Aktivität",
|
|
||||||
"activityPlural": "Aktivitäten",
|
|
||||||
"recentTaskAriaLabel": "Aktuelle Aufgabe:",
|
|
||||||
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:",
|
|
||||||
"errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben",
|
|
||||||
"errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,6 @@
|
|||||||
"deleteConfirmationOk": "Ja",
|
"deleteConfirmationOk": "Ja",
|
||||||
"deleteConfirmationCancel": "Abbrechen",
|
"deleteConfirmationCancel": "Abbrechen",
|
||||||
|
|
||||||
"deleteTaskTitle": "Aufgabe löschen",
|
|
||||||
"deleteTaskContent": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
|
||||||
"deleteTaskConfirm": "Löschen",
|
|
||||||
"deleteTaskCancel": "Abbrechen",
|
|
||||||
|
|
||||||
"deleteStatusTitle": "Status löschen",
|
|
||||||
"deleteStatusContent": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
|
||||||
|
|
||||||
"deletePhaseTitle": "Phase löschen",
|
|
||||||
"deletePhaseContent": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
|
||||||
|
|
||||||
"dueDate": "Fälligkeitsdatum",
|
"dueDate": "Fälligkeitsdatum",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
|
||||||
@@ -37,17 +26,5 @@
|
|||||||
"noDueDate": "Kein Fälligkeitsdatum",
|
"noDueDate": "Kein Fälligkeitsdatum",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"clear": "Löschen",
|
"clear": "Löschen",
|
||||||
"nextWeek": "Nächste Woche",
|
"nextWeek": "Nächste Woche"
|
||||||
"noSubtasks": "Keine Unteraufgaben",
|
|
||||||
"showSubtasks": "Unteraufgaben anzeigen",
|
|
||||||
"hideSubtasks": "Unteraufgaben ausblenden",
|
|
||||||
|
|
||||||
"errorLoadingTasks": "Fehler beim Laden der Aufgaben",
|
|
||||||
"noTasksFound": "Keine Aufgaben gefunden",
|
|
||||||
"loadingFilters": "Filter werden geladen...",
|
|
||||||
"failedToUpdateColumnOrder": "Fehler beim Aktualisieren der Spaltenreihenfolge",
|
|
||||||
"failedToUpdatePhaseOrder": "Fehler beim Aktualisieren der Phasenreihenfolge",
|
|
||||||
"pleaseTryAgain": "Bitte versuchen Sie es erneut",
|
|
||||||
"taskNotCompleted": "Aufgabe ist nicht abgeschlossen",
|
|
||||||
"completeTaskDependencies": "Bitte schließen Sie die Aufgabenabhängigkeiten ab, bevor Sie fortfahren"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,5 @@
|
|||||||
"deleteButtonTooltip": "Aus Projekt entfernen",
|
"deleteButtonTooltip": "Aus Projekt entfernen",
|
||||||
"memberCount": "Mitglied",
|
"memberCount": "Mitglied",
|
||||||
"membersCountPlural": "Mitglieder",
|
"membersCountPlural": "Mitglieder",
|
||||||
"emptyText": "Es gibt keine Anhänge in diesem Projekt.",
|
"emptyText": "Es gibt keine Anhänge in diesem Projekt."
|
||||||
"searchPlaceholder": "Mitglieder suchen"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,5 @@
|
|||||||
"searchLabel": "Mitglieder hinzufügen durch Eingabe von Name oder E-Mail",
|
"searchLabel": "Mitglieder hinzufügen durch Eingabe von Name oder E-Mail",
|
||||||
"searchPlaceholder": "Name oder E-Mail eingeben",
|
"searchPlaceholder": "Name oder E-Mail eingeben",
|
||||||
"inviteAsAMember": "Als Mitglied einladen",
|
"inviteAsAMember": "Als Mitglied einladen",
|
||||||
"inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen",
|
"inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen"
|
||||||
"members": "Mitglieder",
|
|
||||||
"copyProjectLink": "Projektlink kopieren",
|
|
||||||
"inviteMember": "Mitglied einladen",
|
|
||||||
"alsoInviteToProject": "Auch zum Projekt einladen"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Jobtitel",
|
"jobTitleLabel": "Jobtitel",
|
||||||
"jobTitlePlaceholder": "Jobtitel auswählen oder suchen (optional)",
|
"jobTitlePlaceholder": "Jobtitel auswählen oder suchen (optional)",
|
||||||
"memberAccessLabel": "Zugriffslevel",
|
"memberAccessLabel": "Zugriffslevel",
|
||||||
"addToTeamButton": "Einladung senden",
|
"addToTeamButton": "Mitglied zum Team hinzufügen",
|
||||||
"updateButton": "Änderungen speichern",
|
"updateButton": "Änderungen speichern",
|
||||||
"resendInvitationButton": "Einladungs-E-Mail erneut senden",
|
"resendInvitationButton": "Einladungs-E-Mail erneut senden",
|
||||||
"invitationSentSuccessMessage": "Team-Einladung erfolgreich versendet!",
|
"invitationSentSuccessMessage": "Team-Einladung erfolgreich versendet!",
|
||||||
@@ -38,11 +38,16 @@
|
|||||||
"updateMemberErrorMessage": "Aktualisierung des Teammitglieds fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
"updateMemberErrorMessage": "Aktualisierung des Teammitglieds fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
"memberText": "Mitglied",
|
"memberText": "Mitglied",
|
||||||
"adminText": "Administrator",
|
"adminText": "Administrator",
|
||||||
|
"guestText": "Gast (Nur Lesen)",
|
||||||
"ownerText": "Team-Besitzer",
|
"ownerText": "Team-Besitzer",
|
||||||
"addedText": "Hinzugefügt",
|
"addedText": "Hinzugefügt",
|
||||||
"updatedText": "Aktualisiert",
|
"updatedText": "Aktualisiert",
|
||||||
"noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...",
|
"noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...",
|
||||||
"jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel",
|
"jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel",
|
||||||
"invitationResent": "Einladung erfolgreich erneut gesendet!",
|
"invitationResent": "Einladung erfolgreich erneut gesendet!",
|
||||||
"copyTeamLink": "Team-Link kopieren"
|
"emailsStepDescription": "Geben Sie E-Mail-Adressen für Teammitglieder ein, die Sie einladen möchten",
|
||||||
|
"personalMessageLabel": "Persönliche Nachricht",
|
||||||
|
"personalMessagePlaceholder": "Fügen Sie eine persönliche Nachricht zu Ihrer Einladung hinzu (optional)",
|
||||||
|
"optionalFieldLabel": "(Optional)",
|
||||||
|
"inviteTeamMembersModalTitle": "Teammitglieder einladen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"close": "Schließen",
|
||||||
"cannotMoveStatus": "Status kann nicht verschoben werden",
|
"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.",
|
"cannotMoveStatusMessage": "Dieser Status kann nicht verschoben werden, da die Kategorie '{{categoryName}}' leer bleiben würde. Jede Kategorie muss mindestens einen Status haben.",
|
||||||
"ok": "OK",
|
"ok": "OK"
|
||||||
"clearSort": "Sortierung löschen",
|
|
||||||
"sortAscending": "Aufsteigend sortieren",
|
|
||||||
"sortDescending": "Absteigend sortieren",
|
|
||||||
"sortByField": "Sortieren nach {{field}}",
|
|
||||||
"ascendingOrder": "Aufsteigend",
|
|
||||||
"descendingOrder": "Absteigend",
|
|
||||||
"currentSort": "Aktuelle Sortierung: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,6 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Mir zuweisen",
|
"assignToMe": "Mir zuweisen",
|
||||||
"copyLink": "Link zur Aufgabe kopieren",
|
|
||||||
"linkCopied": "Link in die Zwischenablage kopiert",
|
|
||||||
"linkCopyFailed": "Fehler beim Kopieren des Links",
|
|
||||||
"moveTo": "Verschieben nach",
|
"moveTo": "Verschieben nach",
|
||||||
"unarchive": "Dearchivieren",
|
"unarchive": "Dearchivieren",
|
||||||
"archive": "Archivieren",
|
"archive": "Archivieren",
|
||||||
@@ -136,11 +133,5 @@
|
|||||||
"dependencies": "Aufgabe hat Abhängigkeiten",
|
"dependencies": "Aufgabe hat Abhängigkeiten",
|
||||||
"recurring": "Wiederkehrende Aufgabe"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,15 @@
|
|||||||
{
|
{
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
|
||||||
"setupYourAccount": "Setup Your Account.",
|
"setupYourAccount": "Setup Your Worklenz Account.",
|
||||||
"organizationStepTitle": "Name Your Organization",
|
"organizationStepTitle": "Name Your Organization",
|
||||||
"organizationStepWelcome": "Welcome to Worklenz!",
|
"organizationStepLabel": "Pick a name for your Worklenz account.",
|
||||||
"organizationStepDescription": "Let's start by setting up your organization. This will be the main workspace for your team.",
|
|
||||||
"organizationStepLabel": "Organization name",
|
|
||||||
"organizationStepPlaceholder": "e.g. Acme Corporation",
|
|
||||||
"organizationStepTooltip": "This name will appear in your workspace and can be changed later in settings.",
|
|
||||||
"organizationStepNeedIdeas": "Need ideas?",
|
|
||||||
"organizationStepUseDetected": "Use detected:",
|
|
||||||
"organizationStepCharacters": "characters",
|
|
||||||
"organizationStepGoodLength": "Good length",
|
|
||||||
"organizationStepTooShort": "Too short",
|
|
||||||
"organizationStepNamingTips": "Naming Tips",
|
|
||||||
"organizationStepTip1": "Keep it simple and memorable",
|
|
||||||
"organizationStepTip2": "Reflect your industry or values",
|
|
||||||
"organizationStepTip3": "Think about future growth",
|
|
||||||
"organizationStepTip4": "Make it unique and brandable",
|
|
||||||
"organizationStepSuggestionsTitle": "Name Suggestions",
|
|
||||||
"organizationStepCategory1": "Tech Companies",
|
|
||||||
"organizationStepCategory2": "Creative Agencies",
|
|
||||||
"organizationStepCategory3": "Consulting",
|
|
||||||
"organizationStepCategory4": "Startups",
|
|
||||||
"organizationStepSuggestionsNote": "These are just examples to get you started. Choose something that represents your organization.",
|
|
||||||
"organizationStepPrivacyNote": "Your organization name is private and only visible to your team members.",
|
|
||||||
|
|
||||||
"projectStepTitle": "Create your first project",
|
"projectStepTitle": "Create your first project",
|
||||||
"projectStepLabel": "What project are you working on right now?",
|
"projectStepLabel": "What project are you working on right now?",
|
||||||
"projectStepPlaceholder": "e.g. Marketing Plan",
|
"projectStepPlaceholder": "e.g. Marketing Plan",
|
||||||
|
|
||||||
|
"tasksStepTitle": "Create your first tasks",
|
||||||
"tasksStepLabel": "Type a few tasks that you are going to do in",
|
"tasksStepLabel": "Type a few tasks that you are going to do in",
|
||||||
"tasksStepAddAnother": "Add another",
|
"tasksStepAddAnother": "Add another",
|
||||||
|
|
||||||
@@ -44,169 +24,8 @@
|
|||||||
"step3InputLabel": "Invite with email",
|
"step3InputLabel": "Invite with email",
|
||||||
"addAnother": "Add another",
|
"addAnother": "Add another",
|
||||||
"skipForNow": "Skip for now",
|
"skipForNow": "Skip for now",
|
||||||
"skipping": "Skipping...",
|
|
||||||
"formTitle": "Create your first task.",
|
"formTitle": "Create your first task.",
|
||||||
"step3Title": "Invite your team to work with",
|
"step3Title": "Invite your team to work with",
|
||||||
"maxMembers": " (You can invite up to 5 members)",
|
"maxMembers": " (You can invite up to 5 members)",
|
||||||
"maxTasks": " (You can create up to 5 tasks)",
|
"maxTasks": " (You can create up to 5 tasks)"
|
||||||
|
|
||||||
"membersStepTitle": "Invite your team",
|
|
||||||
"membersStepDescription": "Add team members to \"{{organizationName}}\" and start collaborating",
|
|
||||||
"memberPlaceholder": "Team member {{index}} - Enter email address",
|
|
||||||
"validEmailAddress": "Valid email address",
|
|
||||||
"addAnotherTeamMember": "Add another team member ({{current}}/{{max}})",
|
|
||||||
"canInviteLater": "You can always invite team members later",
|
|
||||||
"skipStepDescription": "Don't have email addresses ready? No problem! You can skip this step and invite team members from your project dashboard later.",
|
|
||||||
|
|
||||||
"orgCategoryTech": "Tech Companies",
|
|
||||||
"orgCategoryCreative": "Creative Agencies",
|
|
||||||
"orgCategoryConsulting": "Consulting",
|
|
||||||
"orgCategoryStartups": "Startups",
|
|
||||||
"namingTip1": "Keep it simple and memorable",
|
|
||||||
"namingTip2": "Reflect your industry or values",
|
|
||||||
"namingTip3": "Think about future growth",
|
|
||||||
"namingTip4": "Make it unique and brandable",
|
|
||||||
|
|
||||||
"aboutYouTitle": "Tell us about yourself",
|
|
||||||
"aboutYouDescription": "Help us personalize your experience",
|
|
||||||
"orgTypeQuestion": "What best describes your organization?",
|
|
||||||
"userRoleQuestion": "What's your role?",
|
|
||||||
|
|
||||||
"yourNeedsTitle": "What are your main needs?",
|
|
||||||
"yourNeedsDescription": "Select all that apply to help us set up your workspace",
|
|
||||||
"yourNeedsQuestion": "How will you primarily use Worklenz?",
|
|
||||||
"useCaseTaskOrg": "Organize and track tasks",
|
|
||||||
"useCaseTeamCollab": "Work together seamlessly",
|
|
||||||
"useCaseResourceMgmt": "Manage time and resources",
|
|
||||||
"useCaseClientComm": "Stay connected with clients",
|
|
||||||
"useCaseTimeTrack": "Monitor project hours",
|
|
||||||
"useCaseOther": "Something else",
|
|
||||||
"selectedText": "selected",
|
|
||||||
"previousToolsQuestion": "What tools have you used before? (Optional)",
|
|
||||||
|
|
||||||
"discoveryTitle": "One last thing...",
|
|
||||||
"discoveryDescription": "Help us understand how you discovered Worklenz",
|
|
||||||
"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",
|
|
||||||
"stepProgress": "Step {step} of 3: {title}",
|
|
||||||
|
|
||||||
"projectStepHeader": "Let's create your first project",
|
|
||||||
"projectStepSubheader": "Start from scratch or use a template to get going faster",
|
|
||||||
"startFromScratch": "Start from scratch",
|
|
||||||
"templateSelected": "Template selected below",
|
|
||||||
"quickSuggestions": "Quick suggestions:",
|
|
||||||
"orText": "OR",
|
|
||||||
"startWithTemplate": "Start with a template",
|
|
||||||
"clearToSelectTemplate": "Clear project name above to select a template",
|
|
||||||
"templateHeadStart": "Get a head start with pre-built project structures",
|
|
||||||
"browseAllTemplates": "Browse All Templates",
|
|
||||||
"templatesAvailable": "15+ industry-specific templates available",
|
|
||||||
"chooseTemplate": "Choose a template that matches your project type",
|
|
||||||
"createProject": "Create Project",
|
|
||||||
|
|
||||||
"templateSoftwareDev": "Software Development",
|
|
||||||
"templateSoftwareDesc": "Agile sprints, bug tracking, releases",
|
|
||||||
"templateMarketing": "Marketing Campaign",
|
|
||||||
"templateMarketingDesc": "Campaign planning, content calendar",
|
|
||||||
"templateConstruction": "Construction Project",
|
|
||||||
"templateConstructionDesc": "Phases, permits, contractors",
|
|
||||||
"templateStartup": "Startup Launch",
|
|
||||||
"templateStartupDesc": "MVP development, funding, growth",
|
|
||||||
|
|
||||||
"tasksStepTitle": "Add your first tasks",
|
|
||||||
"tasksStepDescription": "Break down \"{{projectName}}\" into actionable tasks to get started",
|
|
||||||
"taskPlaceholder": "Task {{index}} - e.g., What needs to be done?",
|
|
||||||
"addAnotherTask": "Add another task ({{current}}/{{max}})",
|
|
||||||
|
|
||||||
"surveyStepTitle": "Tell us about yourself",
|
|
||||||
"surveyStepLabel": "Help us personalize your Worklenz experience by answering a few questions.",
|
|
||||||
|
|
||||||
"organizationType": "What best describes your organization?",
|
|
||||||
"organizationTypeFreelancer": "Freelancer",
|
|
||||||
"organizationTypeStartup": "Startup",
|
|
||||||
"organizationTypeSmallMediumBusiness": "Small or Medium Business",
|
|
||||||
"organizationTypeAgency": "Agency",
|
|
||||||
"organizationTypeEnterprise": "Enterprise",
|
|
||||||
"organizationTypeOther": "Other",
|
|
||||||
|
|
||||||
"userRole": "What is your role?",
|
|
||||||
"userRoleFounderCeo": "Founder / CEO",
|
|
||||||
"userRoleProjectManager": "Project Manager",
|
|
||||||
"userRoleSoftwareDeveloper": "Software Developer",
|
|
||||||
"userRoleDesigner": "Designer",
|
|
||||||
"userRoleOperations": "Operations",
|
|
||||||
"userRoleOther": "Other",
|
|
||||||
|
|
||||||
"mainUseCases": "What will you mainly use Worklenz for?",
|
|
||||||
"mainUseCasesTaskManagement": "Task management",
|
|
||||||
"mainUseCasesTeamCollaboration": "Team collaboration",
|
|
||||||
"mainUseCasesResourcePlanning": "Resource planning",
|
|
||||||
"mainUseCasesClientCommunication": "Client communication & reporting",
|
|
||||||
"mainUseCasesTimeTracking": "Time tracking",
|
|
||||||
"mainUseCasesOther": "Other",
|
|
||||||
|
|
||||||
"previousTools": "What tool(s) were you using before Worklenz?",
|
|
||||||
"previousToolsPlaceholder": "e.g. Trello, Asana, Monday.com",
|
|
||||||
|
|
||||||
"howHeardAbout": "How did you hear about Worklenz?",
|
|
||||||
"howHeardAboutGoogleSearch": "Google Search",
|
|
||||||
"howHeardAboutTwitter": "Twitter",
|
|
||||||
"howHeardAboutLinkedin": "LinkedIn",
|
|
||||||
"howHeardAboutFriendColleague": "A friend or colleague",
|
|
||||||
"howHeardAboutBlogArticle": "A blog or article",
|
|
||||||
"howHeardAboutOther": "Other",
|
|
||||||
|
|
||||||
"aboutYouStepTitle": "Tell us about yourself",
|
|
||||||
"aboutYouStepDescription": "Help us personalize your experience",
|
|
||||||
"yourNeedsStepTitle": "What are your main needs?",
|
|
||||||
"yourNeedsStepDescription": "Select all that apply to help us set up your workspace",
|
|
||||||
"selected": "selected",
|
|
||||||
"previousToolsLabel": "What tools have you used before? (Optional)",
|
|
||||||
|
|
||||||
"roleSuggestions": {
|
|
||||||
"designer": "UI/UX, Graphics, Creative",
|
|
||||||
"developer": "Frontend, Backend, Full-stack",
|
|
||||||
"projectManager": "Planning, Coordination",
|
|
||||||
"marketing": "Content, Social Media, Growth",
|
|
||||||
"sales": "Business Development, Client Relations",
|
|
||||||
"operations": "Admin, HR, Finance"
|
|
||||||
},
|
|
||||||
|
|
||||||
"languages": {
|
|
||||||
"en": "English",
|
|
||||||
"es": "Español",
|
|
||||||
"pt": "Português",
|
|
||||||
"de": "Deutsch",
|
|
||||||
"alb": "Shqip",
|
|
||||||
"zh": "简体中文"
|
|
||||||
},
|
|
||||||
|
|
||||||
"orgSuggestions": {
|
|
||||||
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
|
||||||
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
|
||||||
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
|
||||||
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"projectSuggestions": {
|
|
||||||
"freelancer": ["Client Project", "Portfolio Update", "Personal Brand"],
|
|
||||||
"startup": ["MVP Development", "Product Launch", "Market Research"],
|
|
||||||
"agency": ["Client Campaign", "Brand Strategy", "Website Redesign"],
|
|
||||||
"enterprise": ["System Migration", "Process Optimization", "Team Training"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"useCaseDescriptions": {
|
|
||||||
"taskManagement": "Organize and track tasks",
|
|
||||||
"teamCollaboration": "Work together seamlessly",
|
|
||||||
"resourcePlanning": "Manage time and resources",
|
|
||||||
"clientCommunication": "Stay connected with clients",
|
|
||||||
"timeTracking": "Monitor project hours",
|
|
||||||
"other": "Something else"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"billingDetails": "Billing Details",
|
|
||||||
"name": "Name",
|
|
||||||
"namePlaceholder": "Name",
|
|
||||||
"emailAddress": "Email Address",
|
|
||||||
"emailPlaceholder": "Email Address",
|
|
||||||
"contactNumber": "Contact Number",
|
|
||||||
"phoneNumberPlaceholder": "Phone Number",
|
|
||||||
"phoneValidationError": "Phone number must be exactly 10 digits",
|
|
||||||
"companyDetails": "Company Details",
|
|
||||||
"companyName": "Company Name",
|
|
||||||
"companyNamePlaceholder": "Company Name",
|
|
||||||
"addressLine01": "Address Line 01",
|
|
||||||
"addressLine01Placeholder": "Address Line 01",
|
|
||||||
"addressLine02": "Address Line 02",
|
|
||||||
"addressLine02Placeholder": "Address Line 02",
|
|
||||||
"country": "Country",
|
|
||||||
"countryPlaceholder": "Country",
|
|
||||||
"city": "City",
|
|
||||||
"cityPlaceholder": "City",
|
|
||||||
"state": "State",
|
|
||||||
"statePlaceholder": "State",
|
|
||||||
"postalCode": "Postal Code",
|
|
||||||
"postalCodePlaceholder": "Postal Code",
|
|
||||||
"save": "Save"
|
|
||||||
}
|
|
||||||
@@ -117,26 +117,5 @@
|
|||||||
"currentSeatsText": "You currently have {{seats}} seats available.",
|
"currentSeatsText": "You currently have {{seats}} seats available.",
|
||||||
"selectSeatsText": "Please select the number of additional seats to purchase.",
|
"selectSeatsText": "Please select the number of additional seats to purchase.",
|
||||||
"purchase": "Purchase",
|
"purchase": "Purchase",
|
||||||
"contactSales": "Contact sales",
|
"contactSales": "Contact sales"
|
||||||
"submitSuccess": "Code redeemed successfully!",
|
|
||||||
"submitSuccessDescription": "Your account has been updated with the new credits.",
|
|
||||||
"percentUsed": "% Used",
|
|
||||||
"sizeUnits": {
|
|
||||||
"bytes": "Bytes",
|
|
||||||
"kb": "KB",
|
|
||||||
"mb": "MB",
|
|
||||||
"gb": "GB",
|
|
||||||
"tb": "TB"
|
|
||||||
},
|
|
||||||
"seatPerMonth": "seat / month",
|
|
||||||
"totalPrice": "Total $",
|
|
||||||
"tryForFree": "Try for free",
|
|
||||||
"subscriptionUpdateSuccess": "Subscription updated successfully!",
|
|
||||||
"paymentProcessorError": "Failed to load payment processor",
|
|
||||||
"seatsLabel": "Seats:",
|
|
||||||
"requiredField": "*",
|
|
||||||
"purchaseSeatsTextSingle": "To continue, you'll need to purchase an additional seat.",
|
|
||||||
"singleUserNote": "You currently have 1 seat available.",
|
|
||||||
"selectSeatsTextSingle": "Please select the number of additional seats to purchase.",
|
|
||||||
"phoneNumberPattern": "07xxxxxxxx"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,5 @@
|
|||||||
"owner": "Organization Owner",
|
"owner": "Organization Owner",
|
||||||
"admins": "Organization Admins",
|
"admins": "Organization Admins",
|
||||||
"contactNumber": "Add Contact Number",
|
"contactNumber": "Add Contact Number",
|
||||||
"edit": "Edit",
|
"edit": "Edit"
|
||||||
"emailAddress": "Email Address",
|
|
||||||
"enterOrganizationName": "Enter organization name",
|
|
||||||
"ownerSuffix": " (Owner)"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,5 @@
|
|||||||
"user": "User",
|
"user": "User",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"lastActivity": "Last Activity",
|
"lastActivity": "Last Activity",
|
||||||
"refresh": "Refresh users",
|
"refresh": "Refresh users"
|
||||||
"name": "Name"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,5 @@
|
|||||||
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
|
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
|
||||||
"reconnecting": "Disconnected from server.",
|
"reconnecting": "Disconnected from server.",
|
||||||
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
||||||
"connection-restored": "Connected to server successfully",
|
"connection-restored": "Connected to server successfully"
|
||||||
"cancel": "Cancel"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,22 +41,6 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh"
|
||||||
"recentActivity": "Recent Activity",
|
|
||||||
"recentTasks": "Recent Tasks",
|
|
||||||
"recentTasksSegment": "Recent Tasks",
|
|
||||||
"timeLogged": "Time Logged",
|
|
||||||
"timeLoggedSegment": "Time Logged",
|
|
||||||
"noRecentTasks": "No recent tasks",
|
|
||||||
"noTimeLoggedTasks": "No time logged tasks",
|
|
||||||
"activityTag": "Activity",
|
|
||||||
"timeLogTag": "Time Log",
|
|
||||||
"timerTag": "Timer",
|
|
||||||
"activitySingular": "activity",
|
|
||||||
"activityPlural": "activities",
|
|
||||||
"recentTaskAriaLabel": "Recent task:",
|
|
||||||
"timeLoggedTaskAriaLabel": "Time logged task:",
|
|
||||||
"errorLoadingRecentTasks": "Error loading recent tasks",
|
|
||||||
"errorLoadingTimeLoggedTasks": "Error loading time logged tasks"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,6 @@
|
|||||||
"deleteConfirmationOk": "Yes",
|
"deleteConfirmationOk": "Yes",
|
||||||
"deleteConfirmationCancel": "Cancel",
|
"deleteConfirmationCancel": "Cancel",
|
||||||
|
|
||||||
"deleteTaskTitle": "Delete Task",
|
|
||||||
"deleteTaskContent": "Are you sure you want to delete this task? This action cannot be undone.",
|
|
||||||
"deleteTaskConfirm": "Delete",
|
|
||||||
"deleteTaskCancel": "Cancel",
|
|
||||||
|
|
||||||
"deleteStatusTitle": "Delete Status",
|
|
||||||
"deleteStatusContent": "Are you sure you want to delete this status? This action cannot be undone.",
|
|
||||||
|
|
||||||
"deletePhaseTitle": "Delete Phase",
|
|
||||||
"deletePhaseContent": "Are you sure you want to delete this phase? This action cannot be undone.",
|
|
||||||
|
|
||||||
"dueDate": "Due date",
|
"dueDate": "Due date",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
||||||
@@ -40,14 +29,5 @@
|
|||||||
"nextWeek": "Next week",
|
"nextWeek": "Next week",
|
||||||
"noSubtasks": "No subtasks",
|
"noSubtasks": "No subtasks",
|
||||||
"showSubtasks": "Show subtasks",
|
"showSubtasks": "Show subtasks",
|
||||||
"hideSubtasks": "Hide subtasks",
|
"hideSubtasks": "Hide subtasks"
|
||||||
|
|
||||||
"errorLoadingTasks": "Error loading tasks",
|
|
||||||
"noTasksFound": "No tasks found",
|
|
||||||
"loadingFilters": "Loading filters...",
|
|
||||||
"failedToUpdateColumnOrder": "Failed to update column order",
|
|
||||||
"failedToUpdatePhaseOrder": "Failed to update phase order",
|
|
||||||
"pleaseTryAgain": "Please try again",
|
|
||||||
"taskNotCompleted": "Task is not completed",
|
|
||||||
"completeTaskDependencies": "Please complete the task dependencies before proceeding"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,5 @@
|
|||||||
"deleteButtonTooltip": "Remove from project",
|
"deleteButtonTooltip": "Remove from project",
|
||||||
"memberCount": "Member",
|
"memberCount": "Member",
|
||||||
"membersCountPlural": "Members",
|
"membersCountPlural": "Members",
|
||||||
"emptyText": "There are no attachments in the project.",
|
"emptyText": "There are no attachments in the project."
|
||||||
"searchPlaceholder": "Search members"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Share Project",
|
"title": "Project Members",
|
||||||
"searchLabel": "Add members by adding their name or email",
|
"searchLabel": "Add members by adding their name or email",
|
||||||
"searchPlaceholder": "Type name or email",
|
"searchPlaceholder": "Type name or email",
|
||||||
"inviteAsAMember": "Invite as a member",
|
"inviteAsAMember": "Invite as a member",
|
||||||
"inviteNewMemberByEmail": "Invite new member by email",
|
"inviteNewMemberByEmail": "Invite new member by email"
|
||||||
"members": "Members",
|
|
||||||
"copyProjectLink": "Copy project link",
|
|
||||||
"inviteMember": "Invite Member",
|
|
||||||
"alsoInviteToProject": "Also invite to project"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"cancelText": "No, cancel",
|
"cancelText": "No, cancel",
|
||||||
"deactivatedText": "(Currently deactivated)",
|
"deactivatedText": "(Currently deactivated)",
|
||||||
"pendingInvitationText": "(Invitation pending)",
|
"pendingInvitationText": "(Invitation pending)",
|
||||||
"addMemberDrawerTitle": "Invite Team Members",
|
"addMemberDrawerTitle": "Add New Team Member",
|
||||||
"updateMemberDrawerTitle": "Update Team Member",
|
"updateMemberDrawerTitle": "Update Team Member",
|
||||||
"addMemberEmailHint": "Members will be added to the team regardless of invitation acceptance status",
|
"addMemberEmailHint": "Members will be added to the team regardless of invitation acceptance status",
|
||||||
"memberEmailLabel": "Email(s)",
|
"memberEmailLabel": "Email(s)",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Job Title",
|
"jobTitleLabel": "Job Title",
|
||||||
"jobTitlePlaceholder": "Select or search job title (Optional)",
|
"jobTitlePlaceholder": "Select or search job title (Optional)",
|
||||||
"memberAccessLabel": "Access Level",
|
"memberAccessLabel": "Access Level",
|
||||||
"addToTeamButton": "Send Invitation",
|
"addToTeamButton": "Add Member to Team",
|
||||||
"updateButton": "Save Changes",
|
"updateButton": "Save Changes",
|
||||||
"resendInvitationButton": "Resend Invitation Email",
|
"resendInvitationButton": "Resend Invitation Email",
|
||||||
"invitationSentSuccessMessage": "Team invitation sent successfully!",
|
"invitationSentSuccessMessage": "Team invitation sent successfully!",
|
||||||
@@ -38,11 +38,16 @@
|
|||||||
"updateMemberErrorMessage": "Failed to update team member. Please try again.",
|
"updateMemberErrorMessage": "Failed to update team member. Please try again.",
|
||||||
"memberText": "Member",
|
"memberText": "Member",
|
||||||
"adminText": "Admin",
|
"adminText": "Admin",
|
||||||
|
"guestText": "Guest (Read-only)",
|
||||||
"ownerText": "Team Owner",
|
"ownerText": "Team Owner",
|
||||||
"addedText": "Added",
|
"addedText": "Added",
|
||||||
"updatedText": "Updated",
|
"updatedText": "Updated",
|
||||||
"noResultFound": "Type an email address and hit enter...",
|
"noResultFound": "Type an email address and hit enter...",
|
||||||
"jobTitlesFetchError": "Failed to fetch job titles",
|
"jobTitlesFetchError": "Failed to fetch job titles",
|
||||||
"invitationResent": "Invitation resent successfully!",
|
"invitationResent": "Invitation resent successfully!",
|
||||||
"copyTeamLink": "Copy team link"
|
"emailsStepDescription": "Enter email addresses for team members you'd like to invite",
|
||||||
|
"personalMessageLabel": "Personal Message",
|
||||||
|
"personalMessagePlaceholder": "Add a personal message to your invitation (optional)",
|
||||||
|
"optionalFieldLabel": "(Optional)",
|
||||||
|
"inviteTeamMembersModalTitle": "Invite team members"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"close": "Close",
|
||||||
"cannotMoveStatus": "Cannot Move Status",
|
"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.",
|
"cannotMoveStatusMessage": "Cannot move this status because it would leave the '{{categoryName}}' category empty. Each category must have at least one status.",
|
||||||
"ok": "OK",
|
"ok": "OK"
|
||||||
"clearSort": "Clear Sort",
|
|
||||||
"sortAscending": "Sort Ascending",
|
|
||||||
"sortDescending": "Sort Descending",
|
|
||||||
"sortByField": "Sort by {{field}}",
|
|
||||||
"ascendingOrder": "Ascending",
|
|
||||||
"descendingOrder": "Descending",
|
|
||||||
"currentSort": "Current sort: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,6 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Assign to me",
|
"assignToMe": "Assign to me",
|
||||||
"copyLink": "Copy link to task",
|
|
||||||
"linkCopied": "Link copied to clipboard",
|
|
||||||
"linkCopyFailed": "Failed to copy link",
|
|
||||||
"moveTo": "Move to",
|
"moveTo": "Move to",
|
||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
@@ -136,11 +133,5 @@
|
|||||||
"dependencies": "Task has dependencies",
|
"dependencies": "Task has dependencies",
|
||||||
"recurring": "Recurring task"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,7 @@
|
|||||||
|
|
||||||
"setupYourAccount": "Configura tu cuenta.",
|
"setupYourAccount": "Configura tu cuenta.",
|
||||||
"organizationStepTitle": "Nombra tu organización",
|
"organizationStepTitle": "Nombra tu organización",
|
||||||
"organizationStepWelcome": "¡Bienvenido a Worklenz!",
|
"organizationStepLabel": "Elige un nombre para tu cuenta de Worklenz.",
|
||||||
"organizationStepDescription": "Comencemos configurando tu organización. Este será el espacio de trabajo principal para tu equipo.",
|
|
||||||
"organizationStepLabel": "Nombre de la organización",
|
|
||||||
"organizationStepPlaceholder": "ej. Corporación Acme",
|
|
||||||
"organizationStepTooltip": "Este nombre aparecerá en tu espacio de trabajo y se puede cambiar más tarde en la configuración.",
|
|
||||||
"organizationStepNeedIdeas": "¿Necesitas ideas?",
|
|
||||||
"organizationStepUseDetected": "Usar detectado:",
|
|
||||||
"organizationStepCharacters": "caracteres",
|
|
||||||
"organizationStepGoodLength": "Buena longitud",
|
|
||||||
"organizationStepTooShort": "Demasiado corto",
|
|
||||||
"organizationStepNamingTips": "Consejos para nombrar",
|
|
||||||
"organizationStepTip1": "Manténlo simple y memorable",
|
|
||||||
"organizationStepTip2": "Refleja tu industria o valores",
|
|
||||||
"organizationStepTip3": "Piensa en el crecimiento futuro",
|
|
||||||
"organizationStepTip4": "Hazlo único y reconocible",
|
|
||||||
"organizationStepSuggestionsTitle": "Sugerencias de nombres",
|
|
||||||
"organizationStepCategory1": "Empresas tecnológicas",
|
|
||||||
"organizationStepCategory2": "Agencias creativas",
|
|
||||||
"organizationStepCategory3": "Consultoría",
|
|
||||||
"organizationStepCategory4": "Startups",
|
|
||||||
"organizationStepSuggestionsNote": "Estos son solo ejemplos para empezar. Elige algo que represente a tu organización.",
|
|
||||||
"organizationStepPrivacyNote": "El nombre de tu organización es privado y solo visible para los miembros de tu equipo.",
|
|
||||||
|
|
||||||
"projectStepTitle": "Crea tu primer proyecto",
|
"projectStepTitle": "Crea tu primer proyecto",
|
||||||
"projectStepLabel": "¿En qué proyecto estás trabajando ahora?",
|
"projectStepLabel": "¿En qué proyecto estás trabajando ahora?",
|
||||||
@@ -45,171 +24,9 @@
|
|||||||
"step3InputLabel": "Invitar por correo electrónico",
|
"step3InputLabel": "Invitar por correo electrónico",
|
||||||
"addAnother": "Agregar otro",
|
"addAnother": "Agregar otro",
|
||||||
"skipForNow": "Omitir por ahora",
|
"skipForNow": "Omitir por ahora",
|
||||||
"skipping": "Omitiendo...",
|
|
||||||
"formTitle": "Crea tu primera tarea.",
|
"formTitle": "Crea tu primera tarea.",
|
||||||
"step3Title": "Invita a tu equipo a trabajar",
|
"step3Title": "Invita a tu equipo a trabajar",
|
||||||
|
|
||||||
"maxMembers": " (Puedes invitar hasta 5 miembros)",
|
"maxMembers": " (Puedes invitar hasta 5 miembros)",
|
||||||
"maxTasks": " (Puedes crear hasta 5 tareas)",
|
"maxTasks": " (Puedes crear hasta 5 tareas)"
|
||||||
|
|
||||||
"membersStepTitle": "Invita a tu equipo",
|
|
||||||
"membersStepDescription": "Añade miembros del equipo a \"{{organizationName}}\" y comienza a colaborar",
|
|
||||||
"memberPlaceholder": "Miembro del equipo {{index}} - Ingresa dirección de correo",
|
|
||||||
"validEmailAddress": "Dirección de correo válida",
|
|
||||||
"addAnotherTeamMember": "Añadir otro miembro del equipo ({{current}}/{{max}})",
|
|
||||||
"canInviteLater": "Siempre puedes invitar miembros del equipo más tarde",
|
|
||||||
"skipStepDescription": "¿No tienes direcciones de correo listas? ¡No hay problema! Puedes omitir este paso e invitar miembros del equipo desde tu panel de proyecto más tarde.",
|
|
||||||
|
|
||||||
"orgCategoryTech": "Empresas Tecnológicas",
|
|
||||||
"orgCategoryCreative": "Agencias Creativas",
|
|
||||||
"orgCategoryConsulting": "Consultoría",
|
|
||||||
"orgCategoryStartups": "Startups",
|
|
||||||
"namingTip1": "Manténlo simple y memorable",
|
|
||||||
"namingTip2": "Refleja tu industria o valores",
|
|
||||||
"namingTip3": "Piensa en el crecimiento futuro",
|
|
||||||
"namingTip4": "Hazlo único y reconocible",
|
|
||||||
|
|
||||||
"aboutYouTitle": "Cuéntanos sobre ti",
|
|
||||||
"aboutYouDescription": "Ayúdanos a personalizar tu experiencia",
|
|
||||||
"orgTypeQuestion": "¿Qué describe mejor tu organización?",
|
|
||||||
"userRoleQuestion": "¿Cuál es tu rol?",
|
|
||||||
|
|
||||||
"yourNeedsTitle": "¿Cuáles son tus principales necesidades?",
|
|
||||||
"yourNeedsDescription": "Selecciona todas las que apliquen para ayudarnos a configurar tu espacio de trabajo",
|
|
||||||
"yourNeedsQuestion": "¿Cómo usarás principalmente Worklenz?",
|
|
||||||
"useCaseTaskOrg": "Organizar y hacer seguimiento de tareas",
|
|
||||||
"useCaseTeamCollab": "Trabajar juntos sin problemas",
|
|
||||||
"useCaseResourceMgmt": "Gestionar tiempo y recursos",
|
|
||||||
"useCaseClientComm": "Mantenerse conectado con clientes",
|
|
||||||
"useCaseTimeTrack": "Monitorear horas de proyecto",
|
|
||||||
"useCaseOther": "Algo más",
|
|
||||||
"selectedText": "seleccionado",
|
|
||||||
"previousToolsQuestion": "¿Qué herramientas has usado antes? (Opcional)",
|
|
||||||
"previousToolsPlaceholder": "ej., Asana, Trello, Jira, Monday.com, etc.",
|
|
||||||
|
|
||||||
"discoveryTitle": "Una última cosa...",
|
|
||||||
"discoveryDescription": "Ayúdanos a entender cómo descubriste Worklenz",
|
|
||||||
"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",
|
|
||||||
"stepProgress": "Paso {step} de 3: {title}",
|
|
||||||
|
|
||||||
"projectStepHeader": "Vamos a crear tu primer proyecto",
|
|
||||||
"projectStepSubheader": "Empieza desde cero o usa una plantilla para ir más rápido",
|
|
||||||
"startFromScratch": "Empezar desde cero",
|
|
||||||
"templateSelected": "Plantilla seleccionada abajo",
|
|
||||||
"quickSuggestions": "Sugerencias rápidas:",
|
|
||||||
"orText": "O",
|
|
||||||
"startWithTemplate": "Comenzar con una plantilla",
|
|
||||||
"clearToSelectTemplate": "Borra el nombre del proyecto arriba para seleccionar una plantilla",
|
|
||||||
"templateHeadStart": "Obtén una ventaja inicial con estructuras de proyecto pre-construidas",
|
|
||||||
"browseAllTemplates": "Explorar todas las plantillas",
|
|
||||||
"templatesAvailable": "15+ plantillas específicas de industria disponibles",
|
|
||||||
"chooseTemplate": "Elige una plantilla que coincida con tu tipo de proyecto",
|
|
||||||
"createProject": "Crear proyecto",
|
|
||||||
|
|
||||||
"templateSoftwareDev": "Desarrollo de Software",
|
|
||||||
"templateSoftwareDesc": "Sprints ágiles, seguimiento de errores, lanzamientos",
|
|
||||||
"templateMarketing": "Campaña de Marketing",
|
|
||||||
"templateMarketingDesc": "Planificación de campaña, calendario de contenido",
|
|
||||||
"templateConstruction": "Proyecto de Construcción",
|
|
||||||
"templateConstructionDesc": "Fases, permisos, contratistas",
|
|
||||||
"templateStartup": "Lanzamiento de Startup",
|
|
||||||
"templateStartupDesc": "Desarrollo MVP, financiación, crecimiento",
|
|
||||||
|
|
||||||
"tasksStepTitle": "Añade tus primeras tareas",
|
|
||||||
"tasksStepDescription": "Desglosa \"{{projectName}}\" en tareas accionables para comenzar",
|
|
||||||
"taskPlaceholder": "Tarea {{index}} - ej., ¿Qué necesita hacerse?",
|
|
||||||
"addAnotherTask": "Añadir otra tarea ({{current}}/{{max}})",
|
|
||||||
|
|
||||||
"surveyStepTitle": "Cuéntanos sobre ti",
|
|
||||||
"surveyStepLabel": "Ayúdanos a personalizar tu experiencia de Worklenz respondiendo algunas preguntas.",
|
|
||||||
|
|
||||||
"organizationType": "¿Qué describe mejor tu organización?",
|
|
||||||
"organizationTypeFreelancer": "Freelancer",
|
|
||||||
"organizationTypeStartup": "Startup",
|
|
||||||
"organizationTypeSmallMediumBusiness": "Pequeña o Mediana Empresa",
|
|
||||||
"organizationTypeAgency": "Agencia",
|
|
||||||
"organizationTypeEnterprise": "Empresa",
|
|
||||||
"organizationTypeOther": "Otro",
|
|
||||||
|
|
||||||
"userRole": "¿Cuál es tu rol?",
|
|
||||||
"userRoleFounderCeo": "Fundador / CEO",
|
|
||||||
"userRoleProjectManager": "Gerente de Proyecto",
|
|
||||||
"userRoleSoftwareDeveloper": "Desarrollador de Software",
|
|
||||||
"userRoleDesigner": "Diseñador",
|
|
||||||
"userRoleOperations": "Operaciones",
|
|
||||||
"userRoleOther": "Otro",
|
|
||||||
|
|
||||||
"mainUseCases": "¿Para qué usarás principalmente Worklenz?",
|
|
||||||
"mainUseCasesTaskManagement": "Gestión de tareas",
|
|
||||||
"mainUseCasesTeamCollaboration": "Colaboración de equipo",
|
|
||||||
"mainUseCasesResourcePlanning": "Planificación de recursos",
|
|
||||||
"mainUseCasesClientCommunication": "Comunicación con clientes e informes",
|
|
||||||
"mainUseCasesTimeTracking": "Seguimiento de tiempo",
|
|
||||||
"mainUseCasesOther": "Otro",
|
|
||||||
|
|
||||||
"previousTools": "¿Qué herramienta(s) usabas antes de Worklenz?",
|
|
||||||
"previousToolsPlaceholder": "ej. Trello, Asana, Monday.com",
|
|
||||||
|
|
||||||
"howHeardAbout": "¿Cómo conociste Worklenz?",
|
|
||||||
"howHeardAboutGoogleSearch": "Búsqueda de Google",
|
|
||||||
"howHeardAboutTwitter": "Twitter",
|
|
||||||
"howHeardAboutLinkedin": "LinkedIn",
|
|
||||||
"howHeardAboutFriendColleague": "Un amigo o colega",
|
|
||||||
"howHeardAboutBlogArticle": "Un blog o artículo",
|
|
||||||
"howHeardAboutOther": "Otro",
|
|
||||||
|
|
||||||
"aboutYouStepTitle": "Cuéntanos sobre ti",
|
|
||||||
"aboutYouStepDescription": "Ayúdanos a personalizar tu experiencia",
|
|
||||||
"yourNeedsStepTitle": "¿Cuáles son tus principales necesidades?",
|
|
||||||
"yourNeedsStepDescription": "Selecciona todas las que apliquen para ayudarnos a configurar tu espacio de trabajo",
|
|
||||||
"selected": "seleccionado",
|
|
||||||
"previousToolsLabel": "¿Qué herramientas has usado antes? (Opcional)",
|
|
||||||
|
|
||||||
"roleSuggestions": {
|
|
||||||
"designer": "UI/UX, Gráficos, Creativo",
|
|
||||||
"developer": "Frontend, Backend, Full-stack",
|
|
||||||
"projectManager": "Planificación, Coordinación",
|
|
||||||
"marketing": "Contenido, Redes Sociales, Crecimiento",
|
|
||||||
"sales": "Desarrollo de Negocios, Relaciones con Clientes",
|
|
||||||
"operations": "Administración, RRHH, Finanzas"
|
|
||||||
},
|
|
||||||
|
|
||||||
"languages": {
|
|
||||||
"en": "English",
|
|
||||||
"es": "Español",
|
|
||||||
"pt": "Português",
|
|
||||||
"de": "Deutsch",
|
|
||||||
"alb": "Shqip",
|
|
||||||
"zh": "简体中文"
|
|
||||||
},
|
|
||||||
|
|
||||||
"orgSuggestions": {
|
|
||||||
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
|
||||||
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
|
||||||
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
|
||||||
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"projectSuggestions": {
|
|
||||||
"freelancer": ["Proyecto Cliente", "Actualización Portfolio", "Marca Personal"],
|
|
||||||
"startup": ["Desarrollo MVP", "Lanzamiento Producto", "Investigación Mercado"],
|
|
||||||
"agency": ["Campaña Cliente", "Estrategia Marca", "Rediseño Website"],
|
|
||||||
"enterprise": ["Migración Sistema", "Optimización Procesos", "Capacitación Equipo"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"useCaseDescriptions": {
|
|
||||||
"taskManagement": "Organizar y rastrear tareas",
|
|
||||||
"teamCollaboration": "Trabajar juntos sin problemas",
|
|
||||||
"resourcePlanning": "Gestionar tiempo y recursos",
|
|
||||||
"clientCommunication": "Mantenerse conectado con clientes",
|
|
||||||
"timeTracking": "Monitorear horas de proyecto",
|
|
||||||
"other": "Algo más"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
"emailLabel": "Correo electrónico",
|
"emailLabel": "Correo electrónico",
|
||||||
"emailPlaceholder": "Ingresa tu correo electrónico",
|
"emailPlaceholder": "Ingresa tu correo electrónico",
|
||||||
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
|
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
|
||||||
"passwordLabel": "Contraseña",
|
"passwordLabel": "Password",
|
||||||
"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.",
|
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
||||||
"passwordPlaceholder": "Ingresa tu contraseña",
|
"passwordPlaceholder": "Enter your password",
|
||||||
"passwordRequired": "¡Por favor ingresa tu contraseña!",
|
"passwordRequired": "¡Por favor ingresa tu contraseña!",
|
||||||
"passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!",
|
"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!",
|
"passwordPatternRequired": "¡La contraseña no cumple con los requisitos!",
|
||||||
"strongPasswordPlaceholder": "Ingresa una contraseña más segura",
|
"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.",
|
"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.",
|
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
|
||||||
"reconnecting": "Reconectando al servidor...",
|
"reconnecting": "Reconectando al servidor...",
|
||||||
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
||||||
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
|
"connection-restored": "Conexión restaurada. Reconectando al servidor..."
|
||||||
"cancel": "Cancelar"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,22 +40,6 @@
|
|||||||
"list": "Lista",
|
"list": "Lista",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"tasks": "Tareas",
|
"tasks": "Tareas",
|
||||||
"refresh": "Actualizar",
|
"refresh": "Actualizar"
|
||||||
"recentActivity": "Actividad Reciente",
|
|
||||||
"recentTasks": "Tareas Recientes",
|
|
||||||
"recentTasksSegment": "Tareas Recientes",
|
|
||||||
"timeLogged": "Tiempo Registrado",
|
|
||||||
"timeLoggedSegment": "Tiempo Registrado",
|
|
||||||
"noRecentTasks": "No hay tareas recientes",
|
|
||||||
"noTimeLoggedTasks": "No hay tareas con tiempo registrado",
|
|
||||||
"activityTag": "Actividad",
|
|
||||||
"timeLogTag": "Registro de Tiempo",
|
|
||||||
"timerTag": "Temporizador",
|
|
||||||
"activitySingular": "actividad",
|
|
||||||
"activityPlural": "actividades",
|
|
||||||
"recentTaskAriaLabel": "Tarea reciente:",
|
|
||||||
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:",
|
|
||||||
"errorLoadingRecentTasks": "Error al cargar tareas recientes",
|
|
||||||
"errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,6 @@
|
|||||||
"deleteConfirmationOk": "Sí",
|
"deleteConfirmationOk": "Sí",
|
||||||
"deleteConfirmationCancel": "Cancelar",
|
"deleteConfirmationCancel": "Cancelar",
|
||||||
|
|
||||||
"deleteTaskTitle": "Eliminar tarea",
|
|
||||||
"deleteTaskContent": "¿Estás seguro de que deseas eliminar esta tarea? Esta acción no se puede deshacer.",
|
|
||||||
"deleteTaskConfirm": "Eliminar",
|
|
||||||
"deleteTaskCancel": "Cancelar",
|
|
||||||
|
|
||||||
"deleteStatusTitle": "Eliminar estado",
|
|
||||||
"deleteStatusContent": "¿Estás seguro de que deseas eliminar este estado? Esta acción no se puede deshacer.",
|
|
||||||
|
|
||||||
"deletePhaseTitle": "Eliminar fase",
|
|
||||||
"deletePhaseContent": "¿Estás seguro de que deseas eliminar esta fase? Esta acción no se puede deshacer.",
|
|
||||||
|
|
||||||
"dueDate": "Fecha de vencimiento",
|
"dueDate": "Fecha de vencimiento",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|
||||||
@@ -37,17 +26,5 @@
|
|||||||
"noDueDate": "Sin fecha de vencimiento",
|
"noDueDate": "Sin fecha de vencimiento",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"clear": "Limpiar",
|
"clear": "Limpiar",
|
||||||
"nextWeek": "Próxima semana",
|
"nextWeek": "Próxima semana"
|
||||||
"noSubtasks": "Sin subtareas",
|
|
||||||
"showSubtasks": "Mostrar subtareas",
|
|
||||||
"hideSubtasks": "Ocultar subtareas",
|
|
||||||
|
|
||||||
"errorLoadingTasks": "Error al cargar tareas",
|
|
||||||
"noTasksFound": "No se encontraron tareas",
|
|
||||||
"loadingFilters": "Cargando filtros...",
|
|
||||||
"failedToUpdateColumnOrder": "Error al actualizar el orden de las columnas",
|
|
||||||
"failedToUpdatePhaseOrder": "Error al actualizar el orden de las fases",
|
|
||||||
"pleaseTryAgain": "Por favor, inténtalo de nuevo",
|
|
||||||
"taskNotCompleted": "La tarea no está completada",
|
|
||||||
"completeTaskDependencies": "Por favor, completa las dependencias de la tarea antes de continuar"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,5 @@
|
|||||||
"deleteButtonTooltip": "Eliminar del proyecto",
|
"deleteButtonTooltip": "Eliminar del proyecto",
|
||||||
"memberCount": "Miembro",
|
"memberCount": "Miembro",
|
||||||
"membersCountPlural": "Miembros",
|
"membersCountPlural": "Miembros",
|
||||||
"emptyText": "No hay archivos adjuntos en el proyecto.",
|
"emptyText": "No hay archivos adjuntos en el proyecto."
|
||||||
"searchPlaceholder": "Buscar miembros"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,5 @@
|
|||||||
"searchLabel": "Agregar miembros ingresando su nombre o correo electrónico",
|
"searchLabel": "Agregar miembros ingresando su nombre o correo electrónico",
|
||||||
"searchPlaceholder": "Escriba nombre o correo electrónico",
|
"searchPlaceholder": "Escriba nombre o correo electrónico",
|
||||||
"inviteAsAMember": "Invitar como miembro",
|
"inviteAsAMember": "Invitar como miembro",
|
||||||
"inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico",
|
"inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico"
|
||||||
"members": "Miembros",
|
|
||||||
"copyProjectLink": "Copiar enlace del proyecto",
|
|
||||||
"inviteMember": "Invitar miembro",
|
|
||||||
"alsoInviteToProject": "También invitar al proyecto"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Cargo",
|
"jobTitleLabel": "Cargo",
|
||||||
"jobTitlePlaceholder": "Seleccione o busque cargo (Opcional)",
|
"jobTitlePlaceholder": "Seleccione o busque cargo (Opcional)",
|
||||||
"memberAccessLabel": "Nivel de acceso",
|
"memberAccessLabel": "Nivel de acceso",
|
||||||
"addToTeamButton": "Enviar invitación",
|
"addToTeamButton": "Agregar miembro al equipo",
|
||||||
"updateButton": "Guardar cambios",
|
"updateButton": "Guardar cambios",
|
||||||
"resendInvitationButton": "Reenviar correo de invitación",
|
"resendInvitationButton": "Reenviar correo de invitación",
|
||||||
"invitationSentSuccessMessage": "¡Invitación al equipo enviada exitosamente!",
|
"invitationSentSuccessMessage": "¡Invitación al equipo enviada exitosamente!",
|
||||||
@@ -38,11 +38,16 @@
|
|||||||
"updateMemberErrorMessage": "Error al actualizar miembro del equipo. Por favor, intente nuevamente.",
|
"updateMemberErrorMessage": "Error al actualizar miembro del equipo. Por favor, intente nuevamente.",
|
||||||
"memberText": "Miembro del equipo",
|
"memberText": "Miembro del equipo",
|
||||||
"adminText": "Administrador",
|
"adminText": "Administrador",
|
||||||
|
"guestText": "Invitado (Solo lectura)",
|
||||||
"ownerText": "Propietario del equipo",
|
"ownerText": "Propietario del equipo",
|
||||||
"addedText": "Agregado",
|
"addedText": "Agregado",
|
||||||
"updatedText": "Actualizado",
|
"updatedText": "Actualizado",
|
||||||
"noResultFound": "Escriba una dirección de correo electrónico y presione enter...",
|
"noResultFound": "Escriba una dirección de correo electrónico y presione enter...",
|
||||||
"jobTitlesFetchError": "Error al obtener los cargos",
|
"jobTitlesFetchError": "Error al obtener los cargos",
|
||||||
"invitationResent": "¡Invitación reenviada exitosamente!",
|
"invitationResent": "¡Invitación reenviada exitosamente!",
|
||||||
"copyTeamLink": "Copiar enlace del equipo"
|
"emailsStepDescription": "Ingrese las direcciones de correo de los miembros del equipo que desea invitar",
|
||||||
|
"personalMessageLabel": "Mensaje Personal",
|
||||||
|
"personalMessagePlaceholder": "Agregue un mensaje personal a su invitación (opcional)",
|
||||||
|
"optionalFieldLabel": "(Opcional)",
|
||||||
|
"inviteTeamMembersModalTitle": "Invitar miembros del equipo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"close": "Cerrar",
|
||||||
"cannotMoveStatus": "No se puede mover el estado",
|
"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.",
|
"cannotMoveStatusMessage": "No se puede mover este estado porque dejaría vacía la categoría '{{categoryName}}'. Cada categoría debe tener al menos un estado.",
|
||||||
"ok": "OK",
|
"ok": "OK"
|
||||||
"clearSort": "Limpiar Ordenamiento",
|
|
||||||
"sortAscending": "Ordenar Ascendente",
|
|
||||||
"sortDescending": "Ordenar Descendente",
|
|
||||||
"sortByField": "Ordenar por {{field}}",
|
|
||||||
"ascendingOrder": "Ascendente",
|
|
||||||
"descendingOrder": "Descendente",
|
|
||||||
"currentSort": "Ordenamiento actual: {{field}} {{order}}"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,6 @@
|
|||||||
|
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"assignToMe": "Asignar a mí",
|
"assignToMe": "Asignar a mí",
|
||||||
"copyLink": "Copiar enlace a la tarea",
|
|
||||||
"linkCopied": "Enlace copiado al portapapeles",
|
|
||||||
"linkCopyFailed": "Error al copiar el enlace",
|
|
||||||
"moveTo": "Mover a",
|
"moveTo": "Mover a",
|
||||||
"unarchive": "Desarchivar",
|
"unarchive": "Desarchivar",
|
||||||
"archive": "Archivar",
|
"archive": "Archivar",
|
||||||
@@ -136,11 +133,5 @@
|
|||||||
"dependencies": "La tarea tiene dependencias",
|
"dependencies": "La tarea tiene dependencias",
|
||||||
"recurring": "Tarea recurrente"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,7 @@
|
|||||||
|
|
||||||
"setupYourAccount": "Configure sua conta.",
|
"setupYourAccount": "Configure sua conta.",
|
||||||
"organizationStepTitle": "Nomeie sua organização",
|
"organizationStepTitle": "Nomeie sua organização",
|
||||||
"organizationStepWelcome": "Bem-vindo ao Worklenz!",
|
"organizationStepLabel": "Escolha um nome para sua conta Worklenz.",
|
||||||
"organizationStepDescription": "Vamos começar configurando sua organização. Este será o espaço de trabalho principal para sua equipe.",
|
|
||||||
"organizationStepLabel": "Nome da organização",
|
|
||||||
"organizationStepPlaceholder": "ex. Corporação Acme",
|
|
||||||
"organizationStepTooltip": "Este nome aparecerá em seu espaço de trabalho e pode ser alterado posteriormente nas configurações.",
|
|
||||||
"organizationStepNeedIdeas": "Precisa de ideias?",
|
|
||||||
"organizationStepUseDetected": "Usar detectado:",
|
|
||||||
"organizationStepCharacters": "caracteres",
|
|
||||||
"organizationStepGoodLength": "Bom comprimento",
|
|
||||||
"organizationStepTooShort": "Muito curto",
|
|
||||||
"organizationStepNamingTips": "Dicas de nomenclatura",
|
|
||||||
"organizationStepTip1": "Mantenha simples e memorável",
|
|
||||||
"organizationStepTip2": "Reflita sua indústria ou valores",
|
|
||||||
"organizationStepTip3": "Pense no crescimento futuro",
|
|
||||||
"organizationStepTip4": "Torne único e marcante",
|
|
||||||
"organizationStepSuggestionsTitle": "Sugestões de nomes",
|
|
||||||
"organizationStepCategory1": "Empresas de tecnologia",
|
|
||||||
"organizationStepCategory2": "Agências criativas",
|
|
||||||
"organizationStepCategory3": "Consultoria",
|
|
||||||
"organizationStepCategory4": "Startups",
|
|
||||||
"organizationStepSuggestionsNote": "Estes são apenas exemplos para começar. Escolha algo que represente sua organização.",
|
|
||||||
"organizationStepPrivacyNote": "O nome da sua organização é privado e visível apenas para os membros da sua equipe.",
|
|
||||||
|
|
||||||
"projectStepTitle": "Crie seu primeiro projeto",
|
"projectStepTitle": "Crie seu primeiro projeto",
|
||||||
"projectStepLabel": "Em qual projeto você está trabalhando agora?",
|
"projectStepLabel": "Em qual projeto você está trabalhando agora?",
|
||||||
@@ -45,171 +24,9 @@
|
|||||||
"step3InputLabel": "Convidar por email",
|
"step3InputLabel": "Convidar por email",
|
||||||
"addAnother": "Adicionar outro",
|
"addAnother": "Adicionar outro",
|
||||||
"skipForNow": "Pular por enquanto",
|
"skipForNow": "Pular por enquanto",
|
||||||
"skipping": "Pulando...",
|
|
||||||
"formTitle": "Crie sua primeira tarefa.",
|
"formTitle": "Crie sua primeira tarefa.",
|
||||||
"step3Title": "Convide sua equipe para trabalhar",
|
"step3Title": "Convide sua equipe para trabalhar",
|
||||||
|
|
||||||
"maxMembers": " (Você pode convidar até 5 membros)",
|
"maxMembers": " (Você pode convidar até 5 membros)",
|
||||||
"maxTasks": " (Você pode criar até 5 tarefas)",
|
"maxTasks": " (Você pode criar até 5 tarefas)"
|
||||||
|
|
||||||
"membersStepTitle": "Convide sua equipe",
|
|
||||||
"membersStepDescription": "Adicione membros da equipe ao \"{{organizationName}}\" e comece a colaborar",
|
|
||||||
"memberPlaceholder": "Membro da equipe {{index}} - Digite o endereço de email",
|
|
||||||
"validEmailAddress": "Endereço de email válido",
|
|
||||||
"addAnotherTeamMember": "Adicionar outro membro da equipe ({{current}}/{{max}})",
|
|
||||||
"canInviteLater": "Você sempre pode convidar membros da equipe mais tarde",
|
|
||||||
"skipStepDescription": "Não tem endereços de email prontos? Sem problema! Você pode pular esta etapa e convidar membros da equipe do seu painel de projeto mais tarde.",
|
|
||||||
|
|
||||||
"orgCategoryTech": "Empresas de Tecnologia",
|
|
||||||
"orgCategoryCreative": "Agências Criativas",
|
|
||||||
"orgCategoryConsulting": "Consultoria",
|
|
||||||
"orgCategoryStartups": "Startups",
|
|
||||||
"namingTip1": "Mantenha simples e memorável",
|
|
||||||
"namingTip2": "Reflita sua indústria ou valores",
|
|
||||||
"namingTip3": "Pense no crescimento futuro",
|
|
||||||
"namingTip4": "Torne único e marcante",
|
|
||||||
|
|
||||||
"aboutYouTitle": "Conte-nos sobre você",
|
|
||||||
"aboutYouDescription": "Ajude-nos a personalizar sua experiência",
|
|
||||||
"orgTypeQuestion": "O que melhor descreve sua organização?",
|
|
||||||
"userRoleQuestion": "Qual é seu papel?",
|
|
||||||
|
|
||||||
"yourNeedsTitle": "Quais são suas principais necessidades?",
|
|
||||||
"yourNeedsDescription": "Selecione todas que se aplicam para nos ajudar a configurar seu espaço de trabalho",
|
|
||||||
"yourNeedsQuestion": "Como você usará principalmente o Worklenz?",
|
|
||||||
"useCaseTaskOrg": "Organizar e acompanhar tarefas",
|
|
||||||
"useCaseTeamCollab": "Trabalhar juntos perfeitamente",
|
|
||||||
"useCaseResourceMgmt": "Gerenciar tempo e recursos",
|
|
||||||
"useCaseClientComm": "Manter-se conectado com clientes",
|
|
||||||
"useCaseTimeTrack": "Monitorar horas do projeto",
|
|
||||||
"useCaseOther": "Algo mais",
|
|
||||||
"selectedText": "selecionado",
|
|
||||||
"previousToolsQuestion": "Que ferramentas você usou antes? (Opcional)",
|
|
||||||
"previousToolsPlaceholder": "ex., Asana, Trello, Jira, Monday.com, etc.",
|
|
||||||
|
|
||||||
"discoveryTitle": "Uma última coisa...",
|
|
||||||
"discoveryDescription": "Ajude-nos a entender como você descobriu o Worklenz",
|
|
||||||
"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",
|
|
||||||
"stepProgress": "Passo {step} de 3: {title}",
|
|
||||||
|
|
||||||
"projectStepHeader": "Vamos criar seu primeiro projeto",
|
|
||||||
"projectStepSubheader": "Comece do zero ou use um modelo para ir mais rápido",
|
|
||||||
"startFromScratch": "Começar do zero",
|
|
||||||
"templateSelected": "Modelo selecionado abaixo",
|
|
||||||
"quickSuggestions": "Sugestões rápidas:",
|
|
||||||
"orText": "OU",
|
|
||||||
"startWithTemplate": "Começar com um modelo",
|
|
||||||
"clearToSelectTemplate": "Limpe o nome do projeto acima para selecionar um modelo",
|
|
||||||
"templateHeadStart": "Obtenha uma vantagem inicial com estruturas de projeto pré-construídas",
|
|
||||||
"browseAllTemplates": "Navegar por todos os modelos",
|
|
||||||
"templatesAvailable": "15+ modelos específicos da indústria disponíveis",
|
|
||||||
"chooseTemplate": "Escolha um modelo que corresponda ao seu tipo de projeto",
|
|
||||||
"createProject": "Criar projeto",
|
|
||||||
|
|
||||||
"templateSoftwareDev": "Desenvolvimento de Software",
|
|
||||||
"templateSoftwareDesc": "Sprints ágeis, rastreamento de bugs, lançamentos",
|
|
||||||
"templateMarketing": "Campanha de Marketing",
|
|
||||||
"templateMarketingDesc": "Planejamento de campanha, calendário de conteúdo",
|
|
||||||
"templateConstruction": "Projeto de Construção",
|
|
||||||
"templateConstructionDesc": "Fases, licenças, empreiteiros",
|
|
||||||
"templateStartup": "Lançamento de Startup",
|
|
||||||
"templateStartupDesc": "Desenvolvimento MVP, financiamento, crescimento",
|
|
||||||
|
|
||||||
"tasksStepTitle": "Adicione suas primeiras tarefas",
|
|
||||||
"tasksStepDescription": "Divida \"{{projectName}}\" em tarefas acionáveis para começar",
|
|
||||||
"taskPlaceholder": "Tarefa {{index}} - ex., O que precisa ser feito?",
|
|
||||||
"addAnotherTask": "Adicionar outra tarefa ({{current}}/{{max}})",
|
|
||||||
|
|
||||||
"surveyStepTitle": "Conte-nos sobre você",
|
|
||||||
"surveyStepLabel": "Ajude-nos a personalizar sua experiência no Worklenz respondendo algumas perguntas.",
|
|
||||||
|
|
||||||
"organizationType": "O que melhor descreve sua organização?",
|
|
||||||
"organizationTypeFreelancer": "Freelancer",
|
|
||||||
"organizationTypeStartup": "Startup",
|
|
||||||
"organizationTypeSmallMediumBusiness": "Pequena ou Média Empresa",
|
|
||||||
"organizationTypeAgency": "Agência",
|
|
||||||
"organizationTypeEnterprise": "Empresa",
|
|
||||||
"organizationTypeOther": "Outro",
|
|
||||||
|
|
||||||
"userRole": "Qual é o seu papel?",
|
|
||||||
"userRoleFounderCeo": "Fundador / CEO",
|
|
||||||
"userRoleProjectManager": "Gerente de Projeto",
|
|
||||||
"userRoleSoftwareDeveloper": "Desenvolvedor de Software",
|
|
||||||
"userRoleDesigner": "Designer",
|
|
||||||
"userRoleOperations": "Operações",
|
|
||||||
"userRoleOther": "Outro",
|
|
||||||
|
|
||||||
"mainUseCases": "Para que você usará principalmente o Worklenz?",
|
|
||||||
"mainUseCasesTaskManagement": "Gerenciamento de tarefas",
|
|
||||||
"mainUseCasesTeamCollaboration": "Colaboração em equipe",
|
|
||||||
"mainUseCasesResourcePlanning": "Planejamento de recursos",
|
|
||||||
"mainUseCasesClientCommunication": "Comunicação com clientes e relatórios",
|
|
||||||
"mainUseCasesTimeTracking": "Controle de tempo",
|
|
||||||
"mainUseCasesOther": "Outro",
|
|
||||||
|
|
||||||
"previousTools": "Que ferramenta(s) você usava antes do Worklenz?",
|
|
||||||
"previousToolsPlaceholder": "ex. Trello, Asana, Monday.com",
|
|
||||||
|
|
||||||
"howHeardAbout": "Como você soube do Worklenz?",
|
|
||||||
"howHeardAboutGoogleSearch": "Busca no Google",
|
|
||||||
"howHeardAboutTwitter": "Twitter",
|
|
||||||
"howHeardAboutLinkedin": "LinkedIn",
|
|
||||||
"howHeardAboutFriendColleague": "Um amigo ou colega",
|
|
||||||
"howHeardAboutBlogArticle": "Um blog ou artigo",
|
|
||||||
"howHeardAboutOther": "Outro",
|
|
||||||
|
|
||||||
"aboutYouStepTitle": "Conte-nos sobre você",
|
|
||||||
"aboutYouStepDescription": "Ajude-nos a personalizar sua experiência",
|
|
||||||
"yourNeedsStepTitle": "Quais são suas principais necessidades?",
|
|
||||||
"yourNeedsStepDescription": "Selecione todas que se aplicam para nos ajudar a configurar seu espaço de trabalho",
|
|
||||||
"selected": "selecionado",
|
|
||||||
"previousToolsLabel": "Que ferramentas você usou antes? (Opcional)",
|
|
||||||
|
|
||||||
"roleSuggestions": {
|
|
||||||
"designer": "UI/UX, Gráficos, Criativo",
|
|
||||||
"developer": "Frontend, Backend, Full-stack",
|
|
||||||
"projectManager": "Planejamento, Coordenação",
|
|
||||||
"marketing": "Conteúdo, Mídias Sociais, Crescimento",
|
|
||||||
"sales": "Desenvolvimento de Negócios, Relacionamento com Clientes",
|
|
||||||
"operations": "Administração, RH, Finanças"
|
|
||||||
},
|
|
||||||
|
|
||||||
"languages": {
|
|
||||||
"en": "English",
|
|
||||||
"es": "Español",
|
|
||||||
"pt": "Português",
|
|
||||||
"de": "Deutsch",
|
|
||||||
"alb": "Shqip",
|
|
||||||
"zh": "简体中文"
|
|
||||||
},
|
|
||||||
|
|
||||||
"orgSuggestions": {
|
|
||||||
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
|
||||||
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
|
||||||
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
|
||||||
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"projectSuggestions": {
|
|
||||||
"freelancer": ["Projeto Cliente", "Atualização Portfolio", "Marca Pessoal"],
|
|
||||||
"startup": ["Desenvolvimento MVP", "Lançamento Produto", "Pesquisa Mercado"],
|
|
||||||
"agency": ["Campanha Cliente", "Estratégia Marca", "Redesign Website"],
|
|
||||||
"enterprise": ["Migração Sistema", "Otimização Processos", "Treinamento Equipe"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"useCaseDescriptions": {
|
|
||||||
"taskManagement": "Organizar e rastrear tarefas",
|
|
||||||
"teamCollaboration": "Trabalhar juntos perfeitamente",
|
|
||||||
"resourcePlanning": "Gerenciar tempo e recursos",
|
|
||||||
"clientCommunication": "Manter-se conectado com clientes",
|
|
||||||
"timeTracking": "Monitorar horas do projeto",
|
|
||||||
"other": "Algo mais"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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...",
|
"reconnecting": "Reconectando ao servidor...",
|
||||||
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
||||||
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
|
"connection-restored": "Conexão restaurada. Reconectando ao servidor..."
|
||||||
"cancel": "Cancelar"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,22 +40,6 @@
|
|||||||
"list": "Lista",
|
"list": "Lista",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"tasks": "Tarefas",
|
"tasks": "Tarefas",
|
||||||
"refresh": "Atualizar",
|
"refresh": "Atualizar"
|
||||||
"recentActivity": "Atividade Recente",
|
|
||||||
"recentTasks": "Tarefas Recentes",
|
|
||||||
"recentTasksSegment": "Tarefas Recentes",
|
|
||||||
"timeLogged": "Tempo Registrado",
|
|
||||||
"timeLoggedSegment": "Tempo Registrado",
|
|
||||||
"noRecentTasks": "Nenhuma tarefa recente",
|
|
||||||
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
|
|
||||||
"activityTag": "Atividade",
|
|
||||||
"timeLogTag": "Registro de Tempo",
|
|
||||||
"timerTag": "Cronômetro",
|
|
||||||
"activitySingular": "atividade",
|
|
||||||
"activityPlural": "atividades",
|
|
||||||
"recentTaskAriaLabel": "Tarefa recente:",
|
|
||||||
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:",
|
|
||||||
"errorLoadingRecentTasks": "Erro ao carregar tarefas recentes",
|
|
||||||
"errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,6 @@
|
|||||||
"deleteConfirmationOk": "Sim",
|
"deleteConfirmationOk": "Sim",
|
||||||
"deleteConfirmationCancel": "Cancelar",
|
"deleteConfirmationCancel": "Cancelar",
|
||||||
|
|
||||||
"deleteTaskTitle": "Excluir Tarefa",
|
|
||||||
"deleteTaskContent": "Tem certeza de que deseja excluir esta tarefa? Esta ação não pode ser desfeita.",
|
|
||||||
"deleteTaskConfirm": "Excluir",
|
|
||||||
"deleteTaskCancel": "Cancelar",
|
|
||||||
|
|
||||||
"deleteStatusTitle": "Excluir Status",
|
|
||||||
"deleteStatusContent": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.",
|
|
||||||
|
|
||||||
"deletePhaseTitle": "Excluir Fase",
|
|
||||||
"deletePhaseContent": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.",
|
|
||||||
|
|
||||||
"dueDate": "Data de vencimento",
|
"dueDate": "Data de vencimento",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|
||||||
@@ -37,17 +26,5 @@
|
|||||||
"noDueDate": "Sem data de vencimento",
|
"noDueDate": "Sem data de vencimento",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"clear": "Limpar",
|
"clear": "Limpar",
|
||||||
"nextWeek": "Próxima semana",
|
"nextWeek": "Próxima semana"
|
||||||
"noSubtasks": "Sem subtarefas",
|
|
||||||
"showSubtasks": "Mostrar subtarefas",
|
|
||||||
"hideSubtasks": "Ocultar subtarefas",
|
|
||||||
|
|
||||||
"errorLoadingTasks": "Erro ao carregar tarefas",
|
|
||||||
"noTasksFound": "Nenhuma tarefa encontrada",
|
|
||||||
"loadingFilters": "Carregando filtros...",
|
|
||||||
"failedToUpdateColumnOrder": "Falha ao atualizar a ordem das colunas",
|
|
||||||
"failedToUpdatePhaseOrder": "Falha ao atualizar a ordem das fases",
|
|
||||||
"pleaseTryAgain": "Por favor, tente novamente",
|
|
||||||
"taskNotCompleted": "Tarefa não está concluída",
|
|
||||||
"completeTaskDependencies": "Por favor, complete as dependências da tarefa antes de prosseguir"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,5 @@
|
|||||||
"deleteButtonTooltip": "Remover do projeto",
|
"deleteButtonTooltip": "Remover do projeto",
|
||||||
"memberCount": "Membro",
|
"memberCount": "Membro",
|
||||||
"membersCountPlural": "Membros",
|
"membersCountPlural": "Membros",
|
||||||
"emptyText": "Não há anexos no projeto.",
|
"emptyText": "Não há anexos no projeto."
|
||||||
"searchPlaceholder": "Pesquisar membros"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,5 @@
|
|||||||
"searchLabel": "Adicionar membros inserindo nome ou e-mail",
|
"searchLabel": "Adicionar membros inserindo nome ou e-mail",
|
||||||
"searchPlaceholder": "Digite nome ou e-mail",
|
"searchPlaceholder": "Digite nome ou e-mail",
|
||||||
"inviteAsAMember": "Convidar como membro",
|
"inviteAsAMember": "Convidar como membro",
|
||||||
"inviteNewMemberByEmail": "Convidar novo membro por e-mail",
|
"inviteNewMemberByEmail": "Convidar novo membro por e-mail"
|
||||||
"members": "Membros",
|
|
||||||
"copyProjectLink": "Copiar link do projeto",
|
|
||||||
"inviteMember": "Convidar membro",
|
|
||||||
"alsoInviteToProject": "Convidar também para o projeto"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Título do Emprego",
|
"jobTitleLabel": "Título do Emprego",
|
||||||
"jobTitlePlaceholder": "Selecione ou pesquise o título do emprego (Opcional)",
|
"jobTitlePlaceholder": "Selecione ou pesquise o título do emprego (Opcional)",
|
||||||
"memberAccessLabel": "Nível de Acesso",
|
"memberAccessLabel": "Nível de Acesso",
|
||||||
"addToTeamButton": "Enviar convite",
|
"addToTeamButton": "Adicionar Membro à Equipe",
|
||||||
"updateButton": "Salvar Alterações",
|
"updateButton": "Salvar Alterações",
|
||||||
"resendInvitationButton": "Redirecionar Email de Convite",
|
"resendInvitationButton": "Redirecionar Email de Convite",
|
||||||
"invitationSentSuccessMessage": "Convite para a equipe enviado com sucesso!",
|
"invitationSentSuccessMessage": "Convite para a equipe enviado com sucesso!",
|
||||||
@@ -43,6 +43,5 @@
|
|||||||
"updatedText": "Atualizado",
|
"updatedText": "Atualizado",
|
||||||
"noResultFound": "Digite um endereço de email e pressione enter...",
|
"noResultFound": "Digite um endereço de email e pressione enter...",
|
||||||
"jobTitlesFetchError": "Falha ao buscar cargos",
|
"jobTitlesFetchError": "Falha ao buscar cargos",
|
||||||
"invitationResent": "Convite reenviado com sucesso!",
|
"invitationResent": "Convite reenviado com sucesso!"
|
||||||
"copyTeamLink": "Copiar link da equipe"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user