jira-pat 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -95
- package/backend/__tests__/projects.test.js +128 -0
- package/backend/index.js +14 -4
- package/backend/routes/issues.js +34 -0
- package/backend/routes/projects.js +11 -0
- package/backend/service/jiraService.js +276 -2
- package/frontend/dist/assets/index-BHcwfjLq.js +434 -0
- package/frontend/dist/assets/{index-C3FqLdJB.css → index-CvGcEGZd.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/package.json +6 -0
- package/package.json +3 -1
- package/frontend/dist/assets/index-Bw-K2Au_.js +0 -232
package/README.md
CHANGED
|
@@ -1,135 +1,155 @@
|
|
|
1
1
|
# Jira Dashboard
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
1. **Direct Jira Cloud Integration**: Connects to the Jira REST API securely via an Express backend.
|
|
8
|
-
2. **CLI Configuration**: Easy setup with the built-in `jira` CLI tool.
|
|
9
|
-
3. **Dynamic Filtering**: Filter by project, status, or keyword.
|
|
10
|
-
4. **Interactive Issue View**:
|
|
11
|
-
- Full issue details in a side drawer or standalone page.
|
|
12
|
-
- Rich text rendering (Markdown/ADF to HTML).
|
|
13
|
-
- Proxying of Jira-hosted images and attachments.
|
|
14
|
-
- Status transitions and user assignment.
|
|
15
|
-
- File uploads directly to Jira tickets.
|
|
16
|
-
5. **Subtasks & Linked Issues**: Visualize and navigate between related tickets.
|
|
17
|
-
6. **Backend Caching**: 60-second in-memory caching for performance and API rate-limiting compliance.
|
|
18
|
-
7. **Fully Tested**: Comprehensive unit and integration tests for both frontend and backend.
|
|
19
|
-
8. **Error Handling**: Robust error boundaries and toast notifications for a smooth experience.
|
|
3
|
+
View and manage your Jira tickets from a simple dashboard — no need to navigate Jira itself.
|
|
20
4
|
|
|
21
5
|
---
|
|
22
6
|
|
|
23
|
-
##
|
|
24
|
-
|
|
25
|
-
### Prerequisites
|
|
7
|
+
## Before You Begin
|
|
26
8
|
|
|
27
|
-
|
|
28
|
-
- An Atlassian account with a Jira Cloud instance
|
|
29
|
-
- A Jira API Token ([Get one here](https://id.atlassian.com/manage-profile/security/api-tokens))
|
|
9
|
+
You'll need three things set up before you can use the dashboard. This only takes about 5 minutes.
|
|
30
10
|
|
|
31
|
-
###
|
|
11
|
+
### 1. Node.js (a behind-the-scenes engine)
|
|
32
12
|
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
git clone https://github.com/yourusername/jira-dashboard.git
|
|
36
|
-
cd jira-dashboard
|
|
37
|
-
npm install
|
|
38
|
-
```
|
|
13
|
+
The dashboard needs Node.js installed on your computer to run. You don't need to know what it does — just think of it as a required engine.
|
|
39
14
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Follow the prompts to enter your **Jira URL**, **Email**, and **API Token**.
|
|
15
|
+
**Check if you already have it:**
|
|
16
|
+
1. Open **Terminal** (Mac) or **Command Prompt** (Windows)
|
|
17
|
+
- Mac: Press `Cmd + Space`, type *Terminal*, hit Enter
|
|
18
|
+
- Windows: Press `Win + R`, type *cmd*, hit Enter
|
|
19
|
+
2. Type `node --version` and press Enter
|
|
20
|
+
3. If you see something like `v18.0.0` or higher, you're all set ✅
|
|
47
21
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
22
|
+
**If you don't have it:**
|
|
23
|
+
1. Go to [nodejs.org](https://nodejs.org)
|
|
24
|
+
2. Click the big **"LTS"** download button (LTS = most stable version)
|
|
25
|
+
3. Install it like any other program
|
|
26
|
+
4. Close and reopen your Terminal window before continuing
|
|
53
27
|
|
|
54
28
|
---
|
|
55
29
|
|
|
56
|
-
|
|
30
|
+
### 2. A Jira API Token (your secure password for the app)
|
|
57
31
|
|
|
58
|
-
|
|
32
|
+
Instead of using your Jira password directly, you'll create a special token just for this app. It's safer and easy to remove if needed.
|
|
59
33
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
34
|
+
**How to create one:**
|
|
35
|
+
1. Go to [Atlassian API Token page](https://id.atlassian.com/manage-profile/security/api-tokens)
|
|
36
|
+
2. Click **"Create API token"**
|
|
37
|
+
3. Give it any name — for example, *Jira Dashboard*
|
|
38
|
+
4. Click **"Create"**, then **copy the token** that appears
|
|
39
|
+
5. Paste it somewhere safe (like a note on your computer) — you'll need it in the next section and **won't be able to see it again**
|
|
64
40
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
* **Reassign**: Click the user's name or avatar to search for and assign the ticket to someone else.
|
|
69
|
-
* **Add Attachments**: Drag and drop files directly onto the "Upload" area in the side panel to add them to the Jira ticket.
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
### 3. Your Jira URL
|
|
70
44
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
* Enter a **Summary** and click **Create**. The ticket is instantly added to your Jira instance.
|
|
45
|
+
This is the web address you normally type to get to Jira at work. It usually looks like one of these:
|
|
46
|
+
- `https://yourcompany.atlassian.net`
|
|
47
|
+
- `https://jira.yourcompany.com`
|
|
75
48
|
|
|
76
49
|
---
|
|
77
50
|
|
|
78
|
-
##
|
|
51
|
+
## Setup & Launch
|
|
79
52
|
|
|
80
|
-
###
|
|
81
|
-
```bash
|
|
82
|
-
cd backend
|
|
83
|
-
npm start
|
|
84
|
-
```
|
|
85
|
-
The API runs on `http://localhost:5000`.
|
|
53
|
+
### Step 1 — Run the one-time setup
|
|
86
54
|
|
|
87
|
-
|
|
55
|
+
Open Terminal (or Command Prompt) and paste this, then press Enter:
|
|
88
56
|
```bash
|
|
89
|
-
|
|
90
|
-
npm run dev
|
|
57
|
+
npx jira-pat config
|
|
91
58
|
```
|
|
92
|
-
|
|
59
|
+
|
|
60
|
+
It will ask you for three things:
|
|
61
|
+
- **Jira URL** — the address from Step 3 above
|
|
62
|
+
- **Email** — the email you use to log into Jira
|
|
63
|
+
- **API Token** — the token you created in Step 2
|
|
64
|
+
|
|
65
|
+
> 🔒 Your details are saved only on your computer and are never sent anywhere else.
|
|
66
|
+
|
|
67
|
+
**Where your details are saved (for your reference):**
|
|
68
|
+
- Mac/Linux: `~/.jira-dashboard-config.json`
|
|
69
|
+
- Windows: `C:\Users\YourName\.jira-dashboard-config.json`
|
|
93
70
|
|
|
94
71
|
---
|
|
95
72
|
|
|
96
|
-
|
|
73
|
+
### Step 2 — Start the dashboard
|
|
97
74
|
|
|
98
|
-
|
|
75
|
+
Each time you want to use the dashboard, run:
|
|
99
76
|
```bash
|
|
100
|
-
|
|
77
|
+
npx jira-pat
|
|
101
78
|
```
|
|
102
79
|
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
cd backend
|
|
106
|
-
npm test
|
|
80
|
+
Your browser should open automatically. If it doesn't, open your browser and go to:
|
|
107
81
|
```
|
|
108
|
-
|
|
109
|
-
### Frontend Tests
|
|
110
|
-
```bash
|
|
111
|
-
cd frontend
|
|
112
|
-
npm test
|
|
82
|
+
http://localhost:5173
|
|
113
83
|
```
|
|
114
84
|
|
|
115
85
|
---
|
|
116
86
|
|
|
117
|
-
##
|
|
87
|
+
## Using the Dashboard
|
|
118
88
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
89
|
+
### Finding your tickets
|
|
90
|
+
|
|
91
|
+
- When you open the dashboard, you'll see all tickets currently assigned to you.
|
|
92
|
+
- Use the **search bar** at the top to look up a ticket by keyword. Tip: press `/` on your keyboard to jump straight to it.
|
|
93
|
+
- Use the **Project** and **Status** dropdowns to filter the list down to what you need.
|
|
94
|
+
|
|
95
|
+
### Viewing and updating a ticket
|
|
96
|
+
|
|
97
|
+
- **Click any row** to open a side panel with the full details.
|
|
98
|
+
- **Change the status** by clicking the status label (e.g. *In Progress*, *Done*).
|
|
99
|
+
- **Reassign** a ticket by clicking the assignee's name and searching for someone else.
|
|
100
|
+
- **Attach a file** by dragging and dropping it onto the side panel.
|
|
101
|
+
|
|
102
|
+
### Creating a new ticket
|
|
103
|
+
|
|
104
|
+
1. Click the blue **+** button in the top-right corner.
|
|
105
|
+
2. Choose a **Project** and **Issue Type** (Task, Bug, Story, etc.).
|
|
106
|
+
3. Write a short **Summary** and click **Create**.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Something Not Working?
|
|
111
|
+
|
|
112
|
+
| What you're seeing | What to try |
|
|
113
|
+
|--------------------|-------------|
|
|
114
|
+
| *"command not found"* | Node.js isn't installed — go back to Step 1 |
|
|
115
|
+
| *"Cannot connect to Jira"* | Check your Jira URL, email, and API token — run `npx jira-pat config` again |
|
|
116
|
+
| *"Port already in use"* | Close other apps or browser tabs that might be using ports 5000 or 5173 |
|
|
117
|
+
| Browser doesn't open | Manually go to `http://localhost:5173` in your browser |
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Features
|
|
122
|
+
|
|
123
|
+
### Dashboard & Search
|
|
124
|
+
- View all Jira tickets assigned to you
|
|
125
|
+
- Search tickets by keyword, issue key, or summary
|
|
126
|
+
- Filter by project and status
|
|
127
|
+
- Keyboard shortcut `/` to quickly access search
|
|
128
|
+
|
|
129
|
+
### Issue Management
|
|
130
|
+
- View full issue details in a side panel
|
|
131
|
+
- Update issue status (transitions)
|
|
132
|
+
- Reassign issues to other users
|
|
133
|
+
- Add and remove labels
|
|
134
|
+
- Update fix versions
|
|
135
|
+
- Upload attachments (drag & drop)
|
|
136
|
+
- **Edit issue summary** — click the pencil icon next to the title
|
|
137
|
+
- **Edit issue description** — click the pencil icon next to the description header
|
|
138
|
+
|
|
139
|
+
### Project Management
|
|
140
|
+
- Create new issues (Task, Bug, Story, Epic, etc.)
|
|
141
|
+
- View available projects
|
|
142
|
+
- Browse project versions/releases
|
|
143
|
+
|
|
144
|
+
### Comments
|
|
145
|
+
- View and add comments on issues
|
|
146
|
+
- Edit your own comments
|
|
147
|
+
- **@mention team members** — type `@` in the comment box to search and mention project members
|
|
148
|
+
|
|
149
|
+
### Real-time Updates
|
|
150
|
+
- Optimistic UI updates for faster feedback
|
|
151
|
+
- Loading states and error handling
|
|
152
|
+
- Toast notifications for actions
|
|
133
153
|
|
|
134
154
|
---
|
|
135
155
|
|
|
@@ -265,5 +265,133 @@ describe('projects routes', () => {
|
|
|
265
265
|
expect(response.body).toHaveProperty('details');
|
|
266
266
|
});
|
|
267
267
|
});
|
|
268
|
+
|
|
269
|
+
describe('GET /api/projects/:projectKey/members', () => {
|
|
270
|
+
it('should fetch project members', async () => {
|
|
271
|
+
const mockMembers = [
|
|
272
|
+
{
|
|
273
|
+
accountId: 'acc1',
|
|
274
|
+
displayName: 'John Doe',
|
|
275
|
+
emailAddress: 'john@example.com',
|
|
276
|
+
avatarUrls: { '48x48': 'http://example.com/avatar1.png' }
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
accountId: 'acc2',
|
|
280
|
+
displayName: 'Jane Smith',
|
|
281
|
+
emailAddress: 'jane@example.com',
|
|
282
|
+
avatarUrls: {}
|
|
283
|
+
}
|
|
284
|
+
];
|
|
285
|
+
jiraService.getProjectMembers.mockResolvedValue(mockMembers);
|
|
286
|
+
|
|
287
|
+
const response = await request(server)
|
|
288
|
+
.get('/api/projects/ADW/members');
|
|
289
|
+
|
|
290
|
+
expect(response.status).toBe(200);
|
|
291
|
+
expect(response.body).toEqual(mockMembers);
|
|
292
|
+
expect(jiraService.getProjectMembers).toHaveBeenCalledWith('ADW', undefined);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should pass query parameter when searching', async () => {
|
|
296
|
+
const mockMembers = [
|
|
297
|
+
{ accountId: 'acc1', displayName: 'John Doe', emailAddress: 'john@example.com', avatarUrls: {} }
|
|
298
|
+
];
|
|
299
|
+
jiraService.getProjectMembers.mockResolvedValue(mockMembers);
|
|
300
|
+
|
|
301
|
+
const response = await request(server)
|
|
302
|
+
.get('/api/projects/ADW/members?query=john');
|
|
303
|
+
|
|
304
|
+
expect(response.status).toBe(200);
|
|
305
|
+
expect(jiraService.getProjectMembers).toHaveBeenCalledWith('ADW', 'john');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should return empty array when no members', async () => {
|
|
309
|
+
jiraService.getProjectMembers.mockResolvedValue([]);
|
|
310
|
+
|
|
311
|
+
const response = await request(server)
|
|
312
|
+
.get('/api/projects/EMPTY/members');
|
|
313
|
+
|
|
314
|
+
expect(response.status).toBe(200);
|
|
315
|
+
expect(response.body).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should handle Jira API errors', async () => {
|
|
319
|
+
jiraService.getProjectMembers.mockRejectedValue(
|
|
320
|
+
new Error('Permission denied')
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const response = await request(server)
|
|
324
|
+
.get('/api/projects/ADW/members');
|
|
325
|
+
|
|
326
|
+
expect(response.status).toBe(500);
|
|
327
|
+
expect(response.body.error).toBe('Failed to fetch project members');
|
|
328
|
+
expect(response.body.details).toContain('Permission denied');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should handle authentication errors', async () => {
|
|
332
|
+
jiraService.getProjectMembers.mockRejectedValue(
|
|
333
|
+
new Error('Jira API Error: 401 - Unauthorized')
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const response = await request(server)
|
|
337
|
+
.get('/api/projects/ADW/members');
|
|
338
|
+
|
|
339
|
+
expect(response.status).toBe(500);
|
|
340
|
+
expect(response.body.error).toBe('Failed to fetch project members');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should handle network errors', async () => {
|
|
344
|
+
jiraService.getProjectMembers.mockRejectedValue(
|
|
345
|
+
new Error('connect ECONNREFUSED')
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const response = await request(server)
|
|
349
|
+
.get('/api/projects/ADW/members');
|
|
350
|
+
|
|
351
|
+
expect(response.status).toBe(500);
|
|
352
|
+
expect(response.body.details).toContain('ECONNREFUSED');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should return members with all properties', async () => {
|
|
356
|
+
const mockMembers = [
|
|
357
|
+
{
|
|
358
|
+
accountId: 'acc123',
|
|
359
|
+
displayName: 'Test User',
|
|
360
|
+
emailAddress: 'test@company.com',
|
|
361
|
+
avatarUrls: {
|
|
362
|
+
'48x48': 'http://example.com/avatar.png',
|
|
363
|
+
'32x32': 'http://example.com/avatar32.png'
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
];
|
|
367
|
+
jiraService.getProjectMembers.mockResolvedValue(mockMembers);
|
|
368
|
+
|
|
369
|
+
const response = await request(server)
|
|
370
|
+
.get('/api/projects/TEST/members');
|
|
371
|
+
|
|
372
|
+
expect(response.body[0]).toHaveProperty('accountId', 'acc123');
|
|
373
|
+
expect(response.body[0]).toHaveProperty('displayName', 'Test User');
|
|
374
|
+
expect(response.body[0]).toHaveProperty('emailAddress', 'test@company.com');
|
|
375
|
+
expect(response.body[0]).toHaveProperty('avatarUrls');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should pass project key to service', async () => {
|
|
379
|
+
jiraService.getProjectMembers.mockResolvedValue([]);
|
|
380
|
+
|
|
381
|
+
await request(server).get('/api/projects/PROJ123/members');
|
|
382
|
+
|
|
383
|
+
expect(jiraService.getProjectMembers).toHaveBeenCalledWith('PROJ123', undefined);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should handle empty query string', async () => {
|
|
387
|
+
jiraService.getProjectMembers.mockResolvedValue([]);
|
|
388
|
+
|
|
389
|
+
const response = await request(server)
|
|
390
|
+
.get('/api/projects/ADW/members?query=');
|
|
391
|
+
|
|
392
|
+
expect(response.status).toBe(200);
|
|
393
|
+
expect(jiraService.getProjectMembers).toHaveBeenCalledWith('ADW', '');
|
|
394
|
+
});
|
|
395
|
+
});
|
|
268
396
|
});
|
|
269
397
|
|
package/backend/index.js
CHANGED
|
@@ -46,18 +46,28 @@ app.get('/api/health', (req, res) => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// Serve frontend static files
|
|
49
|
-
const frontendDist = path.
|
|
49
|
+
const frontendDist = path.resolve(__dirname, '..', 'frontend', 'dist');
|
|
50
|
+
console.log('--- Static Assets Setup ---');
|
|
51
|
+
console.log('Searching for frontend at:', frontendDist);
|
|
52
|
+
|
|
50
53
|
if (require('fs').existsSync(frontendDist)) {
|
|
51
|
-
console.log('Serving
|
|
54
|
+
console.log('✅ Frontend dist found. Serving static files.');
|
|
52
55
|
app.use(express.static(frontendDist));
|
|
53
56
|
|
|
54
|
-
// Catch-all for SPA: If no other route matches (and it's not an API call), serve index.html
|
|
55
57
|
app.use((req, res, next) => {
|
|
56
58
|
if (!req.path.startsWith('/api') && req.method === 'GET') {
|
|
57
|
-
|
|
59
|
+
const indexPath = path.resolve(frontendDist, 'index.html');
|
|
60
|
+
if (require('fs').existsSync(indexPath)) {
|
|
61
|
+
return res.sendFile(indexPath);
|
|
62
|
+
} else {
|
|
63
|
+
console.error('❌ Error: index.html not found even though dist exists!');
|
|
64
|
+
return res.status(404).send('Dashboard files missing (index.html)');
|
|
65
|
+
}
|
|
58
66
|
}
|
|
59
67
|
next();
|
|
60
68
|
});
|
|
69
|
+
} else {
|
|
70
|
+
console.warn('⚠️ Warning: frontend/dist directory NOT found at ' + frontendDist);
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
app.listen(PORT, () => {
|
package/backend/routes/issues.js
CHANGED
|
@@ -270,4 +270,38 @@ router.put('/:issueKey', mutationLimiter, async (req, res) => {
|
|
|
270
270
|
}
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
// PUT /api/issues/:issueKey/summary
|
|
274
|
+
router.put('/:issueKey/summary', mutationLimiter, async (req, res) => {
|
|
275
|
+
const error = validateIssueKey(req.params.issueKey);
|
|
276
|
+
if (error) return res.status(400).json({ error });
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const { summary } = req.body;
|
|
280
|
+
if (!summary || typeof summary !== 'string') {
|
|
281
|
+
return res.status(400).json({ error: 'Summary is required' });
|
|
282
|
+
}
|
|
283
|
+
if (summary.length > 255) {
|
|
284
|
+
return res.status(400).json({ error: 'Summary must be under 255 characters' });
|
|
285
|
+
}
|
|
286
|
+
await jiraService.updateIssueSummary(req.params.issueKey, summary.trim());
|
|
287
|
+
res.json({ success: true });
|
|
288
|
+
} catch (error) {
|
|
289
|
+
res.status(500).json({ error: 'Failed to update summary', details: error.message });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// PUT /api/issues/:issueKey/description
|
|
294
|
+
router.put('/:issueKey/description', mutationLimiter, async (req, res) => {
|
|
295
|
+
const error = validateIssueKey(req.params.issueKey);
|
|
296
|
+
if (error) return res.status(400).json({ error });
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const { description } = req.body;
|
|
300
|
+
await jiraService.updateIssueDescription(req.params.issueKey, description || '');
|
|
301
|
+
res.json({ success: true });
|
|
302
|
+
} catch (error) {
|
|
303
|
+
res.status(500).json({ error: 'Failed to update description', details: error.message });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
273
307
|
module.exports = router;
|
|
@@ -32,4 +32,15 @@ router.get('/:projectKey/versions', async (req, res) => {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
// GET /api/projects/:projectKey/members
|
|
36
|
+
router.get('/:projectKey/members', async (req, res) => {
|
|
37
|
+
try {
|
|
38
|
+
const { query } = req.query;
|
|
39
|
+
const members = await jiraService.getProjectMembers(req.params.projectKey, query);
|
|
40
|
+
res.json(members);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
res.status(500).json({ error: 'Failed to fetch project members', details: error.message });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
35
46
|
module.exports = router;
|