Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TS-46: Fix duplicate task issue when a sync fails #36

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import {
type ProjectConfig,
encrypt,
getDefaultTaskConfig,
matchTaskRegex,
parseProject,
parseTask,
type MatchedTaskResult,
isPrismaClientKnownRequestError,
} from '@timesheeter/web';
import { type TogglIntegrationContext } from '../../../lib';
import { type TaskPair, type TogglTask, timesheeterTaskSelectQuery } from '../data';

export const createTimesheeterTask = async ({
context,
togglTask,
timesheeterProjectId,
}: {
context: TogglIntegrationContext;
togglTask: TogglTask & {
deleted: false;
};
timesheeterProjectId: string;
}): Promise<TaskPair> => {
const matchResult = matchTaskRegex(togglTask.name);

console.log('Creating timesheeter task', togglTask.name, matchResult);

const tryCreateTimesheeterTask = async () =>
await context.prisma.task
.create({
data: {
name: matchResult.variant === 'description-based' ? matchResult.taskName : '',
configSerialized: encrypt(JSON.stringify(getDefaultTaskConfig())),
togglTaskId: togglTask.id,
projectId: timesheeterProjectId,
workspaceId: context.workspaceId,
ticketForTask: await getTicketForTaskQuery({
context,
matchResult,
timesheeterProjectId,
}),
},
select: timesheeterTaskSelectQuery,
})
.then((task) => parseTask(task, false));

try {
const timesheeterTask = await tryCreateTimesheeterTask();
return {
togglTask,
timesheeterTask,
};
} catch (error) {
if (!isPrismaClientKnownRequestError(error)) {
throw error;
}

// Sometimes if a sync fails and is retried, the task already exists, so purge
// the existing one if no entries are associated with it.
if (error.code !== 'P2002') {
throw error;
}

const existingTask = await context.prisma.task.findFirstOrThrow({
where: {
togglTaskId: togglTask.id,
workspaceId: context.workspaceId,
},
select: {
id: true,
timesheetEntries: {
select: {
id: true,
},
},
},
});

if (existingTask.timesheetEntries.length > 0) {
console.log(`Task ${existingTask.id} already exists and has timesheet entries, skipping deletion`);
throw error;
}

// Delete the original task and try again, not quite sure why this is required
// but it sometimes happens where a task is created but not associated with
// the toggl task.
const deletedTask = await context.prisma.task.delete({
where: {
id: existingTask.id,
workspaceId: context.workspaceId,
},
select: {
id: true,
togglTaskId: true,
},
});

if (deletedTask.togglTaskId) {
await context.prisma.togglSyncRecord.deleteMany({
where: {
togglEntityId: deletedTask.togglTaskId,
category: 'Task',
},
});
}

return {
togglTask,
timesheeterTask: await tryCreateTimesheeterTask(),
};
}
};

const getTicketForTaskQuery = async ({
context,
matchResult,
timesheeterProjectId,
}: {
context: TogglIntegrationContext;
matchResult: MatchedTaskResult;
timesheeterProjectId: string;
}) => {
if (matchResult.variant === 'description-based') {
return undefined;
}

const taskPrefix = await getTaskPrefix({
context,
prefix: matchResult.prefix,
timesheeterProjectId,
});

return {
create: {
number: matchResult.taskNumber,
workspace: {
connect: {
id: context.workspaceId,
},
},
taskPrefix: {
connect: {
id: taskPrefix.id,
},
},
},
};
};

const getTaskPrefix = async ({
context: { prisma, workspaceId },
prefix,
timesheeterProjectId,
}: {
context: TogglIntegrationContext;
prefix: string;
timesheeterProjectId: string;
}) => {
const existingTaskPrefix = await prisma.taskPrefix.findUnique({
where: {
prefix_projectId: {
prefix,
projectId: timesheeterProjectId,
},
},
select: {
id: true,
},
});

if (existingTaskPrefix) {
return existingTaskPrefix;
}

const newTaskPrefix = await prisma.taskPrefix.create({
data: {
projectId: timesheeterProjectId,
prefix,
workspaceId,
},
select: {
id: true,
},
});

const timesheeterProject = await prisma.project
.findUniqueOrThrow({
where: {
id: timesheeterProjectId,
},
select: {
configSerialized: true,
},
})
.then((project) => parseProject(project, false));

const updatedConfig = {
...timesheeterProject.config,
taskPrefixes: [...timesheeterProject.config.taskPrefixes, prefix],
} satisfies ProjectConfig;

await prisma.project.update({
where: {
id: timesheeterProjectId,
},
data: {
configSerialized: encrypt(JSON.stringify(updatedConfig)),
},
select: {
id: true,
},
});

return newTaskPrefix;
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
import {
type ProjectConfig,
deleteTask,
encrypt,
getDefaultTaskConfig,
matchTaskRegex,
parseProject,
parseTask,
} from '@timesheeter/web';
import { deleteTask, parseTask } from '@timesheeter/web';
import { toggl } from '../../../api';
import { type TogglIntegrationContext } from '../../../lib';
import { type TaskPair, type TimesheeterTask, type TogglTask, timesheeterTaskSelectQuery } from '../data';
Expand Down Expand Up @@ -46,116 +38,6 @@ export const updateTimesheeterTask = async ({
};
};

export const createTimesheeterTask = async ({
context: { prisma, workspaceId },
togglTask,
timesheeterProjectId,
}: {
context: TogglIntegrationContext;
togglTask: TogglTask & {
deleted: false;
};
timesheeterProjectId: string;
}): Promise<TaskPair> => {
const matchResult = matchTaskRegex(togglTask.name);

const getTicketForTask = async () => {
if (matchResult.variant === 'description-based') {
return undefined;
}

let taskPrefix = await prisma.taskPrefix.findUnique({
where: {
prefix_projectId: {
prefix: matchResult.prefix,
projectId: timesheeterProjectId,
},
},
select: {
id: true,
},
});

if (!taskPrefix) {
taskPrefix = await prisma.taskPrefix.create({
data: {
projectId: timesheeterProjectId,
prefix: matchResult.prefix,
workspaceId,
},
select: {
id: true,
},
});

const timesheeterProject = await prisma.project
.findUniqueOrThrow({
where: {
id: timesheeterProjectId,
},
select: {
configSerialized: true,
},
})
.then((project) => parseProject(project, false));

const updatedConfig = {
...timesheeterProject.config,
taskPrefixes: [...timesheeterProject.config.taskPrefixes, matchResult.prefix],
} satisfies ProjectConfig;

await prisma.project.update({
where: {
id: timesheeterProjectId,
},
data: {
configSerialized: encrypt(JSON.stringify(updatedConfig)),
},
select: {
id: true,
},
});
}

return {
create: {
number: matchResult.taskNumber,
workspace: {
connect: {
id: workspaceId,
},
},
taskPrefix: {
connect: {
id: taskPrefix.id,
},
},
},
};
};

console.log('Creating timesheeter task', togglTask.name, matchResult);

const timesheeterTask = await prisma.task
.create({
data: {
name: matchResult.variant === 'description-based' ? matchResult.taskName : '',
workspaceId,
configSerialized: encrypt(JSON.stringify(getDefaultTaskConfig())),
togglTaskId: togglTask.id,
projectId: timesheeterProjectId,
ticketForTask: await getTicketForTask(),
},
select: timesheeterTaskSelectQuery,
})
.then((task) => parseTask(task, false));

return {
togglTask,
timesheeterTask,
};
};

export const createTogglTask = async ({
context: { axiosClient, prisma, togglWorkspaceId, workspaceId },
timesheeterTask,
Expand Down Expand Up @@ -282,3 +164,4 @@ export const deleteTogglTask = async ({
};

export * from './update-toggl-task';
export * from './create-timesheeter-task';
9 changes: 8 additions & 1 deletion packages/web/src/server/db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PrismaClient as UnderlyingPrismaClient } from '@prisma/client';
import { PrismaClient as UnderlyingPrismaClient, Prisma } from '@prisma/client';
import { env } from '@timesheeter/web/env';

export type PrismaClient = UnderlyingPrismaClient;
Expand Down Expand Up @@ -27,3 +27,10 @@ export const getPrismaClient = async (): Promise<PrismaClient> => {

return new Promise((resolve) => resolve(prisma));
};

export type PrismaClientKnownRequestError = Prisma.PrismaClientKnownRequestError;

export const PrismaInstance = Prisma;

export const isPrismaClientKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
error instanceof PrismaInstance.PrismaClientKnownRequestError;