clocktopus 1.7.0 → 1.8.0
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/dist/dashboard/routes/calendar.js +7 -0
- package/dist/dashboard/routes/clockify.js +6 -1
- package/dist/dashboard/routes/data.js +25 -1
- package/dist/dashboard/routes/jira.js +6 -1
- package/dist/dashboard/routes/status.js +48 -37
- package/dist/dashboard/routes/timer.js +145 -47
- package/dist/dashboard/views.js +342 -32
- package/dist/index.js +110 -46
- package/dist/lib/credentials.js +18 -0
- package/dist/lib/credentials.test.js +13 -0
- package/dist/lib/db.js +32 -4
- package/dist/scripts/log-calendar-events.js +5 -0
- package/package.json +1 -1
|
@@ -3,11 +3,15 @@ import { google } from 'googleapis';
|
|
|
3
3
|
import { getAuthenticatedClient, getRefreshedToken } from '../../lib/google.js';
|
|
4
4
|
import { getLatestToken, storeToken, getEventProject, setEventProject, getActiveProjects } from '../../lib/db.js';
|
|
5
5
|
import { Clockify } from '../../clockify.js';
|
|
6
|
+
import { isClockifyEnabled } from '../../lib/credentials.js';
|
|
6
7
|
// Hardcoded — registered with Google OAuth; cannot vary with CLOCKTOPUS_PORT
|
|
7
8
|
// without re-registering the redirect URI in the Google Cloud console.
|
|
8
9
|
const DASHBOARD_REDIRECT_URI = 'http://localhost:4001/api/google/callback';
|
|
9
10
|
const calendarRoutes = new Hono();
|
|
10
11
|
calendarRoutes.get('/calendar/events', async (c) => {
|
|
12
|
+
if (!isClockifyEnabled()) {
|
|
13
|
+
return c.json({ ok: false, error: 'Calendar sync requires Clockify.' }, 400);
|
|
14
|
+
}
|
|
11
15
|
const start = c.req.query('start');
|
|
12
16
|
const end = c.req.query('end');
|
|
13
17
|
if (!start || !end) {
|
|
@@ -88,6 +92,9 @@ calendarRoutes.get('/calendar/events', async (c) => {
|
|
|
88
92
|
}
|
|
89
93
|
});
|
|
90
94
|
calendarRoutes.post('/calendar/log', async (c) => {
|
|
95
|
+
if (!isClockifyEnabled()) {
|
|
96
|
+
return c.json({ ok: false, error: 'Calendar sync requires Clockify.' }, 400);
|
|
97
|
+
}
|
|
91
98
|
const { entries } = await c.req.json();
|
|
92
99
|
if (!entries || !Array.isArray(entries) || entries.length === 0) {
|
|
93
100
|
return c.json({ ok: false, error: 'Entries array is required.' }, 400);
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import axios from 'axios';
|
|
3
|
-
import { saveCredential } from '../../lib/credentials.js';
|
|
3
|
+
import { saveCredential, setClockifyDisabled } from '../../lib/credentials.js';
|
|
4
4
|
const clockifyRoutes = new Hono();
|
|
5
|
+
clockifyRoutes.post('/clockify/enabled', async (c) => {
|
|
6
|
+
const { enabled } = await c.req.json();
|
|
7
|
+
setClockifyDisabled(!enabled);
|
|
8
|
+
return c.json({ ok: true });
|
|
9
|
+
});
|
|
5
10
|
clockifyRoutes.post('/clockify', async (c) => {
|
|
6
11
|
const { apiKey } = await c.req.json();
|
|
7
12
|
if (!apiKey) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { getRecentSessions, getSessionCount, getActiveProjects, getAllProjects, upsertProjects, toggleProjectActive, } from '../../lib/db.js';
|
|
3
3
|
import { Clockify } from '../../clockify.js';
|
|
4
|
+
import { isClockifyEnabled, isJiraDisabled } from '../../lib/credentials.js';
|
|
5
|
+
import { getJiraTicket } from '../../lib/jira.js';
|
|
4
6
|
const dataRoutes = new Hono();
|
|
5
7
|
// Active projects for timer dropdown
|
|
6
8
|
dataRoutes.get('/projects', (c) => {
|
|
@@ -14,6 +16,9 @@ dataRoutes.get('/projects/all', (c) => {
|
|
|
14
16
|
});
|
|
15
17
|
// Fetch projects from Clockify and save to DB
|
|
16
18
|
dataRoutes.post('/projects/fetch', async (c) => {
|
|
19
|
+
if (!isClockifyEnabled()) {
|
|
20
|
+
return c.json({ ok: false, error: 'Clockify not configured.' }, 400);
|
|
21
|
+
}
|
|
17
22
|
try {
|
|
18
23
|
const clockify = new Clockify();
|
|
19
24
|
const user = await clockify.getUser();
|
|
@@ -48,7 +53,7 @@ dataRoutes.get('/sessions', (c) => {
|
|
|
48
53
|
const projectMap = new Map(allProjects.map((p) => [p.id, p.name]));
|
|
49
54
|
const enriched = sessions.map((s) => ({
|
|
50
55
|
...s,
|
|
51
|
-
projectName: projectMap.get(s.projectId) ?? 'Unknown',
|
|
56
|
+
projectName: s.projectId ? (projectMap.get(s.projectId) ?? 'Unknown') : null,
|
|
52
57
|
}));
|
|
53
58
|
return c.json({
|
|
54
59
|
data: enriched,
|
|
@@ -58,4 +63,23 @@ dataRoutes.get('/sessions', (c) => {
|
|
|
58
63
|
totalPages: Math.ceil(total / limit),
|
|
59
64
|
});
|
|
60
65
|
});
|
|
66
|
+
// Current Jira ticket summary for timer description preview
|
|
67
|
+
dataRoutes.get('/jira/ticket-summary', async (c) => {
|
|
68
|
+
const ticket = (c.req.query('jira') || '').trim().toUpperCase();
|
|
69
|
+
if (!ticket || !/^[A-Z][A-Z0-9]+-\d+$/.test(ticket)) {
|
|
70
|
+
return c.json({ ok: false, error: 'Invalid ticket.' }, 400);
|
|
71
|
+
}
|
|
72
|
+
if (isJiraDisabled())
|
|
73
|
+
return c.json({ ok: true, description: null });
|
|
74
|
+
try {
|
|
75
|
+
const issue = (await getJiraTicket(ticket));
|
|
76
|
+
const summary = issue?.fields?.summary?.trim();
|
|
77
|
+
if (summary)
|
|
78
|
+
return c.json({ ok: true, description: summary });
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
console.warn('Jira summary lookup failed for', ticket, err);
|
|
82
|
+
}
|
|
83
|
+
return c.json({ ok: true, description: null });
|
|
84
|
+
});
|
|
61
85
|
export default dataRoutes;
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import axios from 'axios';
|
|
3
|
-
import { saveCredential } from '../../lib/credentials.js';
|
|
3
|
+
import { saveCredential, setJiraDisabled } from '../../lib/credentials.js';
|
|
4
4
|
import { storeAtlassianToken } from '../../lib/db.js';
|
|
5
5
|
import { getAtlassianAuthUrl, exchangeCodeForTokens, getAccessibleResources } from '../../lib/atlassian.js';
|
|
6
6
|
const jiraRoutes = new Hono();
|
|
7
|
+
jiraRoutes.post('/jira/enabled', async (c) => {
|
|
8
|
+
const { enabled } = await c.req.json();
|
|
9
|
+
setJiraDisabled(!enabled);
|
|
10
|
+
return c.json({ ok: true });
|
|
11
|
+
});
|
|
7
12
|
// OAuth: redirect to Atlassian authorization (browser fallback)
|
|
8
13
|
jiraRoutes.get('/jira/connect', async (c) => {
|
|
9
14
|
try {
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import axios from 'axios';
|
|
3
|
-
import { resolveCredential } from '../../lib/credentials.js';
|
|
3
|
+
import { isClockifyDisabled, isJiraDisabled, resolveCredential } from '../../lib/credentials.js';
|
|
4
4
|
import { getLatestToken, getAtlassianToken } from '../../lib/db.js';
|
|
5
5
|
import { getValidAccessToken } from '../../lib/atlassian.js';
|
|
6
6
|
const statusRoutes = new Hono();
|
|
7
7
|
statusRoutes.get('/status', async (c) => {
|
|
8
8
|
const results = {
|
|
9
9
|
clockify: false,
|
|
10
|
+
clockifyDisabled: isClockifyDisabled(),
|
|
10
11
|
google: false,
|
|
11
12
|
jira: false,
|
|
13
|
+
jiraDisabled: isJiraDisabled(),
|
|
14
|
+
jiraConfigured: false,
|
|
12
15
|
jiraOAuth: false,
|
|
13
16
|
};
|
|
14
17
|
// Check Clockify
|
|
15
18
|
const clockifyKey = resolveCredential('CLOCKIFY_API_KEY');
|
|
16
19
|
if (clockifyKey) {
|
|
17
20
|
results.clockifyKeyHint = '***' + clockifyKey.slice(-4);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
if (!results.clockifyDisabled) {
|
|
22
|
+
try {
|
|
23
|
+
const res = await axios.get('https://api.clockify.me/api/v1/user', {
|
|
24
|
+
headers: { 'X-Api-Key': clockifyKey },
|
|
25
|
+
timeout: 5000,
|
|
26
|
+
});
|
|
27
|
+
results.clockify = res.status === 200;
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
24
30
|
}
|
|
25
|
-
catch { }
|
|
26
31
|
}
|
|
27
32
|
// Check Google — token exists in DB
|
|
28
33
|
const token = getLatestToken();
|
|
@@ -36,29 +41,32 @@ statusRoutes.get('/status', async (c) => {
|
|
|
36
41
|
const storedAtlassianToken = getAtlassianToken();
|
|
37
42
|
if (storedAtlassianToken) {
|
|
38
43
|
results.jiraOAuth = true;
|
|
44
|
+
results.jiraConfigured = true;
|
|
39
45
|
if (storedAtlassianToken.site_url)
|
|
40
46
|
results.jiraSiteUrl = storedAtlassianToken.site_url;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
catch (error) {
|
|
55
|
-
if (axios.isAxiosError(error)) {
|
|
56
|
-
console.error('Jira OAuth status check failed:', error.response?.status, error.response?.data);
|
|
47
|
+
if (!results.jiraDisabled) {
|
|
48
|
+
try {
|
|
49
|
+
const validToken = await getValidAccessToken();
|
|
50
|
+
if (validToken) {
|
|
51
|
+
const res = await axios.get(`https://api.atlassian.com/ex/jira/${validToken.cloud_id}/rest/api/3/myself`, {
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${validToken.access_token}`,
|
|
54
|
+
Accept: 'application/json',
|
|
55
|
+
},
|
|
56
|
+
timeout: 5000,
|
|
57
|
+
});
|
|
58
|
+
results.jira = res.status === 200;
|
|
59
|
+
}
|
|
57
60
|
}
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (axios.isAxiosError(error)) {
|
|
63
|
+
console.error('Jira OAuth status check failed:', error.response?.status, error.response?.data);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
console.error('Jira OAuth status check failed:', error);
|
|
67
|
+
}
|
|
68
|
+
results.jira = false;
|
|
60
69
|
}
|
|
61
|
-
results.jira = false;
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
else {
|
|
@@ -66,17 +74,20 @@ statusRoutes.get('/status', async (c) => {
|
|
|
66
74
|
const jiraToken = resolveCredential('ATLASSIAN_API_TOKEN');
|
|
67
75
|
const jiraEmail = resolveCredential('ATLASSIAN_EMAIL');
|
|
68
76
|
if (jiraUrl && jiraToken && jiraEmail) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
results.jiraConfigured = true;
|
|
78
|
+
if (!results.jiraDisabled) {
|
|
79
|
+
try {
|
|
80
|
+
const res = await axios.get(`${jiraUrl}/myself`, {
|
|
81
|
+
headers: {
|
|
82
|
+
Authorization: `Basic ${Buffer.from(`${jiraEmail}:${jiraToken}`).toString('base64')}`,
|
|
83
|
+
Accept: 'application/json',
|
|
84
|
+
},
|
|
85
|
+
timeout: 5000,
|
|
86
|
+
});
|
|
87
|
+
results.jira = res.status === 200;
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
78
90
|
}
|
|
79
|
-
catch { }
|
|
80
91
|
}
|
|
81
92
|
}
|
|
82
93
|
return c.json(results);
|
|
@@ -2,26 +2,54 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { v4 as uuidv4 } from 'uuid';
|
|
3
3
|
import { Clockify } from '../../clockify.js';
|
|
4
4
|
import { completeLatestSession, deleteSessionById, getOpenSession, getSessionById, logCompletedSession, logSessionStart, setSessionJiraWorklogId, } from '../../lib/db.js';
|
|
5
|
-
import { deleteJiraWorklog, stopJiraTimer } from '../../lib/jira.js';
|
|
5
|
+
import { deleteJiraWorklog, getJiraTicket, stopJiraTimer } from '../../lib/jira.js';
|
|
6
|
+
import { isClockifyEnabled, isJiraDisabled } from '../../lib/credentials.js';
|
|
6
7
|
function extractJiraTicket(description) {
|
|
7
8
|
const match = description.match(/\b([A-Z][A-Z0-9]+-\d+)\b/);
|
|
8
9
|
return match?.[1];
|
|
9
10
|
}
|
|
11
|
+
async function buildJiraDescription(ticket, typed) {
|
|
12
|
+
if (typed && typed !== ticket)
|
|
13
|
+
return typed;
|
|
14
|
+
if (isJiraDisabled())
|
|
15
|
+
return ticket;
|
|
16
|
+
try {
|
|
17
|
+
const issue = (await getJiraTicket(ticket));
|
|
18
|
+
const summary = issue?.fields?.summary?.trim();
|
|
19
|
+
if (summary)
|
|
20
|
+
return ticket + ' ' + summary;
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
console.warn('Jira summary lookup failed for', ticket, err);
|
|
24
|
+
}
|
|
25
|
+
return ticket;
|
|
26
|
+
}
|
|
10
27
|
const timerRoutes = new Hono();
|
|
11
28
|
timerRoutes.get('/timer/active', async (c) => {
|
|
12
29
|
try {
|
|
30
|
+
if (!isClockifyEnabled()) {
|
|
31
|
+
const openSession = getOpenSession();
|
|
32
|
+
if (!openSession)
|
|
33
|
+
return c.json({ active: false });
|
|
34
|
+
return c.json({
|
|
35
|
+
active: true,
|
|
36
|
+
description: openSession.description,
|
|
37
|
+
projectId: openSession.projectId,
|
|
38
|
+
start: openSession.startedAt,
|
|
39
|
+
...(openSession.jiraTicket ? { jiraTicket: openSession.jiraTicket } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
13
42
|
const clockify = new Clockify();
|
|
14
43
|
const user = await clockify.getUser();
|
|
15
44
|
if (!user)
|
|
16
45
|
return c.json({ active: false });
|
|
17
46
|
const timer = await clockify.getActiveTimer(user.defaultWorkspace, user.id);
|
|
18
47
|
if (!timer) {
|
|
19
|
-
// Timer stopped externally (e.g. in Clockify app) — close any lingering open session
|
|
20
48
|
const openSession = getOpenSession();
|
|
21
49
|
if (openSession) {
|
|
22
50
|
const completedAt = new Date().toISOString();
|
|
23
51
|
completeLatestSession(completedAt, false);
|
|
24
|
-
if (openSession.jiraTicket) {
|
|
52
|
+
if (openSession.jiraTicket && !isJiraDisabled()) {
|
|
25
53
|
const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(openSession.startedAt).getTime()) / 1000);
|
|
26
54
|
if (timeSpentSeconds >= 60) {
|
|
27
55
|
try {
|
|
@@ -37,7 +65,6 @@ timerRoutes.get('/timer/active', async (c) => {
|
|
|
37
65
|
}
|
|
38
66
|
return c.json({ active: false });
|
|
39
67
|
}
|
|
40
|
-
// Sync externally-started timers (e.g. from Clockify app or Jira plugin) to DB
|
|
41
68
|
const timerStart = timer.timeInterval.start;
|
|
42
69
|
const jiraTicket = extractJiraTicket(timer.description ?? '');
|
|
43
70
|
const openSession = getOpenSession();
|
|
@@ -59,36 +86,78 @@ timerRoutes.get('/timer/active', async (c) => {
|
|
|
59
86
|
});
|
|
60
87
|
timerRoutes.post('/timer/start', async (c) => {
|
|
61
88
|
const { projectId, description, jiraTicket, billable } = await c.req.json();
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
const cleanDescription = (description ?? '').trim();
|
|
90
|
+
const cleanJira = jiraTicket?.trim() || undefined;
|
|
91
|
+
const clockifyOn = isClockifyEnabled();
|
|
92
|
+
if (clockifyOn) {
|
|
93
|
+
if (!projectId || !cleanDescription) {
|
|
94
|
+
return c.json({ ok: false, error: 'Project and description are required.' }, 400);
|
|
95
|
+
}
|
|
96
|
+
let clockifyStarted = false;
|
|
97
|
+
try {
|
|
98
|
+
const clockify = new Clockify();
|
|
99
|
+
const user = await clockify.getUser();
|
|
100
|
+
if (user) {
|
|
101
|
+
const result = await clockify.startTimer(user.defaultWorkspace, projectId, cleanDescription, cleanJira, billable ?? true);
|
|
102
|
+
if (result)
|
|
103
|
+
clockifyStarted = true;
|
|
104
|
+
else
|
|
105
|
+
console.warn('Clockify startTimer returned null; falling through to Jira-only path.');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.warn('Clockify enabled but getUser failed; falling through to Jira-only path.');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
console.warn('Clockify start threw; falling through to Jira-only path:', err);
|
|
113
|
+
}
|
|
114
|
+
if (clockifyStarted)
|
|
115
|
+
return c.json({ ok: true });
|
|
64
116
|
}
|
|
117
|
+
// Jira-only or local-only path (also used as fallback when Clockify is unreachable)
|
|
118
|
+
if (!cleanJira && !cleanDescription) {
|
|
119
|
+
return c.json({ ok: false, error: 'Description or Jira ticket is required.' }, 400);
|
|
120
|
+
}
|
|
121
|
+
const finalDescription = cleanJira ? await buildJiraDescription(cleanJira, cleanDescription) : cleanDescription;
|
|
122
|
+
const sessionId = uuidv4();
|
|
123
|
+
const startedAt = new Date().toISOString();
|
|
65
124
|
try {
|
|
66
|
-
|
|
67
|
-
const user = await clockify.getUser();
|
|
68
|
-
if (!user)
|
|
69
|
-
return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
|
|
70
|
-
const result = await clockify.startTimer(user.defaultWorkspace, projectId, description, jiraTicket, billable ?? true);
|
|
71
|
-
if (!result)
|
|
72
|
-
return c.json({ ok: false, error: 'Failed to start timer.' }, 500);
|
|
125
|
+
logSessionStart(sessionId, projectId ?? null, finalDescription, startedAt, cleanJira);
|
|
73
126
|
return c.json({ ok: true });
|
|
74
127
|
}
|
|
75
|
-
catch {
|
|
128
|
+
catch (err) {
|
|
129
|
+
console.error('Error starting session:', err);
|
|
76
130
|
return c.json({ ok: false, error: 'Failed to start timer.' }, 500);
|
|
77
131
|
}
|
|
78
132
|
});
|
|
79
133
|
timerRoutes.post('/timer/stop', async (c) => {
|
|
80
134
|
try {
|
|
81
|
-
const clockify = new Clockify();
|
|
82
|
-
const user = await clockify.getUser();
|
|
83
|
-
if (!user)
|
|
84
|
-
return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
|
|
85
135
|
const openSession = getOpenSession();
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
136
|
+
if (isClockifyEnabled()) {
|
|
137
|
+
try {
|
|
138
|
+
const clockify = new Clockify();
|
|
139
|
+
const user = await clockify.getUser();
|
|
140
|
+
if (user) {
|
|
141
|
+
const result = await clockify.stopTimer(user.defaultWorkspace, user.id);
|
|
142
|
+
if (!result)
|
|
143
|
+
console.warn('Clockify stopTimer returned null; proceeding with DB + worklog.');
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
console.warn('Clockify enabled but getUser failed; proceeding with DB + worklog.');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.warn('Clockify stop threw; proceeding with DB + worklog:', err);
|
|
151
|
+
}
|
|
152
|
+
if (!openSession)
|
|
153
|
+
return c.json({ ok: false, error: 'No active timer.' }, 404);
|
|
154
|
+
}
|
|
155
|
+
else if (!openSession) {
|
|
156
|
+
return c.json({ ok: false, error: 'No active timer.' }, 404);
|
|
157
|
+
}
|
|
89
158
|
const completedAt = new Date().toISOString();
|
|
90
159
|
completeLatestSession(completedAt, false);
|
|
91
|
-
if (openSession?.jiraTicket) {
|
|
160
|
+
if (openSession?.jiraTicket && !isJiraDisabled()) {
|
|
92
161
|
const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(openSession.startedAt).getTime()) / 1000);
|
|
93
162
|
if (timeSpentSeconds >= 60) {
|
|
94
163
|
try {
|
|
@@ -109,9 +178,6 @@ timerRoutes.post('/timer/stop', async (c) => {
|
|
|
109
178
|
});
|
|
110
179
|
timerRoutes.post('/timer/log', async (c) => {
|
|
111
180
|
const { projectId, description, start, end, jiraTicket, billable } = await c.req.json();
|
|
112
|
-
if (!projectId) {
|
|
113
|
-
return c.json({ ok: false, error: 'Project is required.' }, 400);
|
|
114
|
-
}
|
|
115
181
|
if (!start || !end) {
|
|
116
182
|
return c.json({ ok: false, error: 'Start and end are required.' }, 400);
|
|
117
183
|
}
|
|
@@ -125,23 +191,51 @@ timerRoutes.post('/timer/log', async (c) => {
|
|
|
125
191
|
}
|
|
126
192
|
const cleanDescription = (description ?? '').trim();
|
|
127
193
|
const cleanJira = jiraTicket?.trim() || undefined;
|
|
194
|
+
const clockifyOn = isClockifyEnabled();
|
|
195
|
+
if (clockifyOn && !projectId) {
|
|
196
|
+
return c.json({ ok: false, error: 'Project is required.' }, 400);
|
|
197
|
+
}
|
|
128
198
|
if (!cleanDescription && !cleanJira) {
|
|
129
199
|
return c.json({ ok: false, error: 'Description or Jira ticket is required.' }, 400);
|
|
130
200
|
}
|
|
201
|
+
const startIso = new Date(startMs).toISOString();
|
|
202
|
+
const endIso = new Date(endMs).toISOString();
|
|
203
|
+
const clockifyDescription = cleanDescription || cleanJira || '';
|
|
204
|
+
let entryId;
|
|
205
|
+
let clockifySucceeded = false;
|
|
131
206
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
207
|
+
if (clockifyOn) {
|
|
208
|
+
try {
|
|
209
|
+
const clockify = new Clockify();
|
|
210
|
+
const user = await clockify.getUser();
|
|
211
|
+
if (user) {
|
|
212
|
+
const entry = await clockify.logTime(user.defaultWorkspace, projectId, startIso, endIso, clockifyDescription, billable ?? true);
|
|
213
|
+
if (entry) {
|
|
214
|
+
entryId = entry.id ?? uuidv4();
|
|
215
|
+
clockifySucceeded = true;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
console.warn('Clockify logTime returned null; falling through to Jira-only path.');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
console.warn('Clockify enabled but getUser failed; falling through to Jira-only path.');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
console.warn('Clockify log threw; falling through to Jira-only path:', err);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!entryId) {
|
|
230
|
+
entryId = uuidv4();
|
|
231
|
+
}
|
|
232
|
+
const finalDescription = clockifySucceeded
|
|
233
|
+
? clockifyDescription
|
|
234
|
+
: cleanJira
|
|
235
|
+
? await buildJiraDescription(cleanJira, cleanDescription)
|
|
236
|
+
: cleanDescription;
|
|
237
|
+
logCompletedSession(entryId, projectId ?? null, finalDescription, startIso, endIso, cleanJira);
|
|
238
|
+
if (cleanJira && !isJiraDisabled()) {
|
|
145
239
|
const timeSpentSeconds = Math.round((endMs - startMs) / 1000);
|
|
146
240
|
if (timeSpentSeconds >= 60) {
|
|
147
241
|
try {
|
|
@@ -169,15 +263,19 @@ timerRoutes.delete('/timer/:id', async (c) => {
|
|
|
169
263
|
if (!session)
|
|
170
264
|
return c.json({ ok: false, error: 'Session not found.' }, 404);
|
|
171
265
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
266
|
+
if (isClockifyEnabled()) {
|
|
267
|
+
const clockify = new Clockify();
|
|
268
|
+
const user = await clockify.getUser();
|
|
269
|
+
if (user) {
|
|
270
|
+
const clockifyOk = await clockify.deleteTimeEntry(user.defaultWorkspace, id);
|
|
271
|
+
if (!clockifyOk)
|
|
272
|
+
console.warn(`Clockify delete returned failure for ${id}; removing local record anyway.`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
console.warn('Clockify enabled but getUser failed; skipping remote delete.');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (session.jiraTicket && session.jiraWorklogId && !isJiraDisabled()) {
|
|
181
279
|
try {
|
|
182
280
|
await deleteJiraWorklog(session.jiraTicket, session.jiraWorklogId);
|
|
183
281
|
}
|