diff --git a/.codex b/.codex new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index 3ca7578a2..90b3a84b0 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -76,11 +76,12 @@ export function authRoutes(fastify: FastifyInstance) { }, }, async (request: FastifyRequest, reply: FastifyReply) => { - let { email, password, admin, name } = request.body as { + let { email, password, admin, name, language } = request.body as { email: string; password: string; admin: boolean; name: string; + language?: string; }; const requester = await checkSession(request); @@ -88,27 +89,32 @@ export function authRoutes(fastify: FastifyInstance) { if (!requester?.isAdmin) { return reply.code(401).send({ message: "Unauthorized", + success: false, }); } + const normalizedEmail = email.toLowerCase().trim(); + // Checks if email already exists let record = await prisma.user.findUnique({ - where: { email }, + where: { email: normalizedEmail }, }); // if exists, return 400 if (record) { return reply.code(400).send({ message: "Email already exists", + success: false, }); } const user = await prisma.user.create({ data: { - email, + email: normalizedEmail, password: await bcrypt.hash(password, 10), name, isAdmin: admin, + language, }, }); @@ -402,76 +408,88 @@ export function authRoutes(fastify: FastifyInstance) { }); } - // Find out which config type it is, then action accordinly - switch (provider) { - case "oidc": - const config = await getOidcConfig(); - if (!config) { - return reply - .code(500) - .send({ error: "OIDC configuration not found" }); - } - - const oidcClient = await getOidcClient(config); - - // Generate codeVerifier and codeChallenge - const codeVerifier = generators.codeVerifier(); - const codeChallenge = generators.codeChallenge(codeVerifier); - - // Generate a random state parameter - const state = generators.state(); + try { + // Find out which config type it is, then action accordinly + switch (provider) { + case "oidc": + const config = await getOidcConfig(); + if (!config) { + return reply + .code(500) + .send({ error: "OIDC configuration not found" }); + } + + const oidcClient = await getOidcClient(config); + + // Generate codeVerifier and codeChallenge + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + + // Generate a random state parameter + const state = generators.state(); + + // Store codeVerifier in cache with s + cache.set(state, { + codeVerifier: codeVerifier, + }); - // Store codeVerifier in cache with s - cache.set(state, { - codeVerifier: codeVerifier, - }); + // Generate authorization URL + const url = oidcClient.authorizationUrl({ + scope: "openid email profile", + response_type: "code", + redirect_uri: config.redirectUri, + code_challenge: codeChallenge, + code_challenge_method: "S256", // Use 'plain' if 'S256' is not supported + state: state, + }); - // Generate authorization URL - const url = oidcClient.authorizationUrl({ - scope: "openid email profile", - response_type: "code", - redirect_uri: config.redirectUri, - code_challenge: codeChallenge, - code_challenge_method: "S256", // Use 'plain' if 'S256' is not supported - state: state, - }); + reply.send({ + type: "oidc", + success: true, + url: url, + }); - reply.send({ - type: "oidc", - success: true, - url: url, - }); + break; + case "oauth": + const oauthProvider = await getOAuthProvider(); - break; - case "oauth": - const oauthProvider = await getOAuthProvider(); + if (!oauthProvider) { + return reply.code(500).send({ + error: `OAuth provider ${provider} configuration not found`, + }); + } - if (!oauthProvider) { - return reply.code(500).send({ - error: `OAuth provider ${provider} configuration not found`, + const client = getOAuthClient({ + ...oauthProvider, + name: oauthProvider.name, }); - } - - const client = getOAuthClient({ - ...oauthProvider, - name: oauthProvider.name, - }); - // Generate authorization URL - const uri = client.authorizeURL({ - redirect_uri: oauthProvider.redirectUri, - scope: oauthProvider.scope, - }); + // Generate authorization URL + const uri = client.authorizeURL({ + redirect_uri: oauthProvider.redirectUri, + scope: oauthProvider.scope, + }); - reply.send({ - type: "oauth", - success: true, - url: uri, - }); + reply.send({ + type: "oauth", + success: true, + url: uri, + }); - break; - default: - break; + break; + default: + return reply.code(200).send({ + success: true, + message: "SSO not enabled", + oauth: false, + }); + } + } catch (err) { + return reply.code(200).send({ + success: false, + message: "SSO configuration invalid", + oauth: false, + }); } } ); diff --git a/apps/api/src/controllers/notebook.ts b/apps/api/src/controllers/notebook.ts index 378dfe575..7190e1547 100644 --- a/apps/api/src/controllers/notebook.ts +++ b/apps/api/src/controllers/notebook.ts @@ -24,22 +24,30 @@ export function notebookRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["document::create"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const { content, title }: any = request.body; - const user = await checkSession(request); - - const data = await prisma.notes.create({ - data: { - title, - note: content, - userId: user!.id, - }, - }); - - await tracking("note_created", {}); - - const { id } = data; - - reply.status(200).send({ success: true, id }); + try { + const { content, title }: any = request.body; + const user = await checkSession(request); + + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const data = await prisma.notes.create({ + data: { + title, + note: content, + userId: user.id, + }, + }); + + await tracking("note_created", {}); + + const { id } = data; + + reply.status(200).send({ success: true, id }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -50,13 +58,21 @@ export function notebookRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["document::read"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const user = await checkSession(request); + try { + const user = await checkSession(request); - const notebooks = await prisma.notes.findMany({ - where: { userId: user!.id }, - }); + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } - reply.status(200).send({ success: true, notebooks: notebooks }); + const notebooks = await prisma.notes.findMany({ + where: { userId: user.id }, + }); + + reply.status(200).send({ success: true, notebooks: notebooks }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -67,15 +83,23 @@ export function notebookRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["document::read"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const user = await checkSession(request); + try { + const user = await checkSession(request); + + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } - const { id }: any = request.params; + const { id }: any = request.params; - const note = await prisma.notes.findUnique({ - where: { userId: user!.id, id: id }, - }); + const note = await prisma.notes.findUnique({ + where: { userId: user.id, id: id }, + }); - reply.status(200).send({ success: true, note }); + reply.status(200).send({ success: true, note }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -86,19 +110,28 @@ export function notebookRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["document::delete"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const user = await checkSession(request); - const { id }: any = request.params; + try { + const user = await checkSession(request); + + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const { id }: any = request.params; - await prisma.notes.delete({ - where: { - id: id, - userId: user!.id, - }, - }); + await prisma.notes.delete({ + where: { + id: id, + userId: user.id, + }, + }); - await tracking("note_deleted", {}); + await tracking("note_deleted", {}); - reply.status(200).send({ success: true }); + reply.status(200).send({ success: true }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -109,24 +142,33 @@ export function notebookRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["document::update"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const user = await checkSession(request); - const { id }: any = request.params; - const { content, title }: any = request.body; - - await prisma.notes.update({ - where: { - id: id, - userId: user!.id, - }, - data: { - title: title, - note: content, - }, - }); - - await tracking("note_updated", {}); - - reply.status(200).send({ success: true }); + try { + const user = await checkSession(request); + + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const { id }: any = request.params; + const { content, title }: any = request.body; + + await prisma.notes.update({ + where: { + id: id, + userId: user.id, + }, + data: { + title: title, + note: content, + }, + }); + + await tracking("note_updated", {}); + + reply.status(200).send({ success: true }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); } diff --git a/apps/api/src/controllers/ticket.ts b/apps/api/src/controllers/ticket.ts index 27ec2e573..b50d98b93 100644 --- a/apps/api/src/controllers/ticket.ts +++ b/apps/api/src/controllers/ticket.ts @@ -46,6 +46,11 @@ export function ticketRoutes(fastify: FastifyInstance) { engineer, type, createdBy, + custom_field_1, + custom_field_2, + custom_field_3, + outcome, + customer_field, }: any = request.body; const user = await checkSession(request); @@ -73,6 +78,11 @@ export function ticketRoutes(fastify: FastifyInstance) { } : undefined, fromImap: false, + custom_field_1, + custom_field_2, + custom_field_3, + outcome, + customer_field, assignedTo: engineer && engineer.name !== "Unassigned" ? { @@ -83,7 +93,7 @@ export function ticketRoutes(fastify: FastifyInstance) { }, }); - if (!email && !validateEmail(email)) { + if (email && validateEmail(email)) { await sendTicketCreate(ticket); } @@ -94,7 +104,9 @@ export function ticketRoutes(fastify: FastifyInstance) { }, }); - await sendAssignedEmail(assgined!.email); + if (assgined?.email) { + await sendAssignedEmail(assgined.email, ticket); + } await assignedNotification(engineer, ticket, user); } @@ -152,6 +164,11 @@ export function ticketRoutes(fastify: FastifyInstance) { engineer, type, createdBy, + custom_field_1, + custom_field_2, + custom_field_3, + outcome, + customer_field, }: any = request.body; const ticket: any = await prisma.ticket.create({ @@ -177,6 +194,11 @@ export function ticketRoutes(fastify: FastifyInstance) { } : undefined, fromImap: false, + custom_field_1, + custom_field_2, + custom_field_3, + outcome, + customer_field, assignedTo: engineer && engineer.name !== "Unassigned" ? { @@ -187,7 +209,7 @@ export function ticketRoutes(fastify: FastifyInstance) { }, }); - if (!email && !validateEmail(email)) { + if (email && validateEmail(email)) { await sendTicketCreate(ticket); } @@ -198,7 +220,9 @@ export function ticketRoutes(fastify: FastifyInstance) { }, }); - await sendAssignedEmail(assgined!.email); + if (assgined?.email) { + await sendAssignedEmail(assgined.email, ticket); + } const user = await checkSession(request); @@ -376,33 +400,40 @@ export function ticketRoutes(fastify: FastifyInstance) { fastify.get( "/api/v1/tickets/all", async (request: FastifyRequest, reply: FastifyReply) => { - const bearer = request.headers.authorization!.split(" ")[1]; - const token = checkToken(bearer); + try { + const user = await checkSession(request); + + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } - const tickets = await prisma.ticket.findMany({ - where: { hidden: false }, - orderBy: [ - { - createdAt: "desc", - }, - ], - include: { - client: { - select: { id: true, name: true, number: true }, - }, - assignedTo: { - select: { id: true, name: true }, - }, - team: { - select: { id: true, name: true }, + const tickets = await prisma.ticket.findMany({ + where: { hidden: false }, + orderBy: [ + { + createdAt: "desc", + }, + ], + include: { + client: { + select: { id: true, name: true, number: true }, + }, + assignedTo: { + select: { id: true, name: true }, + }, + team: { + select: { id: true, name: true }, + }, }, - }, - }); + }); - reply.send({ - tickets: tickets, - sucess: true, - }); + reply.send({ + tickets: tickets, + sucess: true, + }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -411,27 +442,35 @@ export function ticketRoutes(fastify: FastifyInstance) { "/api/v1/tickets/user/open", async (request: FastifyRequest, reply: FastifyReply) => { - const user = await checkSession(request); + try { + const user = await checkSession(request); - const tickets = await prisma.ticket.findMany({ - where: { isComplete: false, userId: user!.id, hidden: false }, - include: { - client: { - select: { id: true, name: true, number: true }, - }, - assignedTo: { - select: { id: true, name: true }, - }, - team: { - select: { id: true, name: true }, + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const tickets = await prisma.ticket.findMany({ + where: { isComplete: false, userId: user.id, hidden: false }, + include: { + client: { + select: { id: true, name: true, number: true }, + }, + assignedTo: { + select: { id: true, name: true }, + }, + team: { + select: { id: true, name: true }, + }, }, - }, - }); + }); - reply.send({ - tickets: tickets, - sucess: true, - }); + reply.send({ + tickets: tickets, + sucess: true, + }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -489,37 +528,62 @@ export function ticketRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["issue::update"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const { id, note, detail, title, priority, status, client }: any = - request.body; - - const user = await checkSession(request); - - const issue = await prisma.ticket.findUnique({ - where: { id: id }, - }); - - await prisma.ticket.update({ - where: { id: id }, - data: { - detail, + try { + const { + id, note, + detail, title, priority, status, - }, - }); + client, + custom_field_1, + custom_field_2, + custom_field_3, + outcome, + customer_field, + }: any = request.body; - if (priority && issue!.priority !== priority) { - await priorityNotification(issue, user, issue!.priority, priority); - } + const user = await checkSession(request); - if (status && issue!.status !== status) { - await statusUpdateNotification(issue, user, status); - } + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } - reply.send({ - success: true, - }); + const issue = await prisma.ticket.findUnique({ + where: { id: id }, + }); + + await prisma.ticket.update({ + where: { id: id }, + data: { + detail, + note, + title, + priority, + status, + custom_field_1, + custom_field_2, + custom_field_3, + outcome, + customer_field, + }, + }); + + if (priority && issue!.priority !== priority) { + await priorityNotification(issue, user, issue!.priority, priority); + } + + if (status && issue!.status !== status) { + await statusUpdateNotification(issue, user, status); + } + + reply.send({ + success: true, + }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -530,42 +594,52 @@ export function ticketRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["issue::transfer"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const { user, id }: any = request.body; + try { + const { user, id }: any = request.body; - const assigner = await checkSession(request); + const assigner = await checkSession(request); - if (user) { - const assigned = await prisma.user.update({ - where: { id: user }, - data: { - tickets: { - connect: { - id: id, + if (!assigner) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + if (user) { + const assigned = await prisma.user.update({ + where: { id: user }, + data: { + tickets: { + connect: { + id: id, + }, }, }, - }, - }); + }); - const { email } = assigned; + const { email } = assigned; - const ticket = await prisma.ticket.findUnique({ - where: { id: id }, - }); + const ticket = await prisma.ticket.findUnique({ + where: { id: id }, + }); - await sendAssignedEmail(email); - await assignedNotification(assigned, ticket, assigner); - } else { - await prisma.ticket.update({ - where: { id: id }, - data: { - userId: null, - }, + if (email) { + await sendAssignedEmail(email, ticket); + } + await assignedNotification(assigned, ticket, assigner); + } else { + await prisma.ticket.update({ + where: { id: id }, + data: { + userId: null, + }, + }); + } + + reply.send({ + success: true, }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); } - - reply.send({ - success: true, - }); } ); @@ -652,43 +726,51 @@ export function ticketRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["issue::comment"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const { text, id, public: public_comment }: any = request.body; + try { + const { text, id, public: public_comment }: any = request.body; - const user = await checkSession(request); + const user = await checkSession(request); - await prisma.comment.create({ - data: { - text: text, - public: public_comment, - ticketId: id, - userId: user!.id, - }, - }); + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } - const ticket = await prisma.ticket.findUnique({ - where: { - id: id, - }, - }); + await prisma.comment.create({ + data: { + text: text, + public: public_comment, + ticketId: id, + userId: user.id, + }, + }); - //@ts-expect-error - const { email, title } = ticket; - if (public_comment && email) { - sendComment(text, title, ticket!.id, email!); - } + const ticket = await prisma.ticket.findUnique({ + where: { + id: id, + }, + }); - await commentNotification(ticket, user); + //@ts-expect-error + const { email, title } = ticket; + if (public_comment && email) { + sendComment(text, title, ticket!.id, email!); + } - const hog = track(); + await commentNotification(ticket, user); - hog.capture({ - event: "ticket_comment", - distinctId: ticket!.id, - }); + const hog = track(); - reply.send({ - success: true, - }); + hog.capture({ + event: "ticket_comment", + distinctId: ticket!.id, + }); + + reply.send({ + success: true, + }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -719,64 +801,88 @@ export function ticketRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["issue::update"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const { status, id }: any = request.body; + try { + const { status, id }: any = request.body; - const user = await checkSession(request); + const user = await checkSession(request); - const ticket: any = await prisma.ticket.update({ - where: { id: id }, - data: { - isComplete: status, - }, - }); + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } - await activeStatusNotification(ticket, user, status); + const previousTicket = await prisma.ticket.findUnique({ + where: { id: id }, + select: { isComplete: true }, + }); - await sendTicketStatus(ticket); + const ticket: any = await prisma.ticket.update({ + where: { id: id }, + data: { + isComplete: status, + }, + include: { + assignedTo: { + select: { + email: true, + }, + }, + }, + }); - const webhook = await prisma.webhooks.findMany({ - where: { - type: "ticket_status_changed", - }, - }); + const statusChanged = previousTicket?.isComplete !== ticket.isComplete; - for (let i = 0; i < webhook.length; i++) { - const url = webhook[i].url; + if (statusChanged) { + await activeStatusNotification(ticket, user, status); - if (webhook[i].active === true) { - const s = status ? "Completed" : "Outstanding"; - if (url.includes("discord.com")) { - const message = { - content: `Ticket ${ticket.id} created by ${ticket.email}, has had it's status changed to ${s}`, - avatar_url: - "https://avatars.githubusercontent.com/u/76014454?s=200&v=4", - username: "Peppermint.sh", - }; - axios - .post(url, message) - .then((response) => { - console.log("Message sent successfully!"); - console.log("Discord API response:", response.data); - }) - .catch((error) => { - console.error("Error sending message:", error); + await sendTicketStatus(ticket); + } + + const webhook = await prisma.webhooks.findMany({ + where: { + type: "ticket_status_changed", + }, + }); + + for (let i = 0; i < webhook.length; i++) { + const url = webhook[i].url; + + if (webhook[i].active === true) { + const s = status ? "Completed" : "Outstanding"; + if (url.includes("discord.com")) { + const message = { + content: `Ticket ${ticket.id} created by ${ticket.email}, has had it's status changed to ${s}`, + avatar_url: + "https://avatars.githubusercontent.com/u/76014454?s=200&v=4", + username: "Peppermint.sh", + }; + axios + .post(url, message) + .then((response) => { + console.log("Message sent successfully!"); + console.log("Discord API response:", response.data); + }) + .catch((error) => { + console.error("Error sending message:", error); + }); + } else { + await axios.post(`${webhook[i].url}`, { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: `Ticket ${ticket.id} created by ${ticket.email}, has had it's status changed to ${s}`, + }), }); - } else { - await axios.post(`${webhook[i].url}`, { - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - data: `Ticket ${ticket.id} created by ${ticket.email}, has had it's status changed to ${s}`, - }), - }); + } } } - } - reply.send({ - success: true, - }); + reply.send({ + success: true, + }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -921,32 +1027,129 @@ export function ticketRoutes(fastify: FastifyInstance) { } ); + // Get a ticket by id for an external user + fastify.get( + "/api/v1/tickets/user/external/:id", + async (request: FastifyRequest, reply: FastifyReply) => { + try { + const user = await checkSession(request); + const { id }: any = request.params; + + if (!user) { + return reply.code(401).send({ + message: "Unauthorized", + success: false, + }); + } + + const ticket = await prisma.ticket.findFirst({ + where: { + id, + email: user.email, + hidden: false, + }, + include: { + client: { + select: { id: true, name: true, number: true, notes: true }, + }, + assignedTo: { + select: { id: true, name: true }, + }, + }, + }); + + if (!ticket) { + return reply.code(404).send({ + message: "Ticket not found", + success: false, + }); + } + + const timeTracking = await prisma.timeTracking.findMany({ + where: { + ticketId: id, + }, + include: { + user: { + select: { + name: true, + }, + }, + }, + }); + + const comments = await prisma.comment.findMany({ + where: { + ticketId: ticket.id, + public: true, + }, + include: { + user: { + select: { + name: true, + }, + }, + }, + }); + + const files = await prisma.ticketFile.findMany({ + where: { + ticketId: id, + }, + }); + + reply.send({ + ticket: { + ...ticket, + comments, + TimeTracking: timeTracking, + files, + }, + success: true, + }); + } catch (error) { + reply.code(500).send({ + message: "Unable to fetch ticket", + success: false, + }); + } + } + ); + // Get all open tickets for an external user fastify.get( "/api/v1/tickets/user/open/external", async (request: FastifyRequest, reply: FastifyReply) => { - const user = await checkSession(request); + try { + const user = await checkSession(request); - const tickets = await prisma.ticket.findMany({ - where: { isComplete: false, email: user!.email, hidden: false }, - include: { - client: { - select: { id: true, name: true, number: true }, - }, - assignedTo: { - select: { id: true, name: true }, - }, - team: { - select: { id: true, name: true }, + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const tickets = await prisma.ticket.findMany({ + where: { isComplete: false, email: user.email, hidden: false }, + include: { + client: { + select: { id: true, name: true, number: true }, + }, + assignedTo: { + select: { id: true, name: true }, + }, + team: { + select: { id: true, name: true }, + }, }, - }, - }); + }); - reply.send({ - tickets: tickets, - sucess: true, - }); + reply.send({ + tickets: tickets, + sucess: true, + }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -955,58 +1158,71 @@ export function ticketRoutes(fastify: FastifyInstance) { "/api/v1/tickets/user/closed/external", async (request: FastifyRequest, reply: FastifyReply) => { - const user = await checkSession(request); + try { + const user = await checkSession(request); - const tickets = await prisma.ticket.findMany({ - where: { isComplete: true, email: user!.email, hidden: false }, - include: { - client: { - select: { id: true, name: true, number: true }, - }, - assignedTo: { - select: { id: true, name: true }, - }, - team: { - select: { id: true, name: true }, + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const tickets = await prisma.ticket.findMany({ + where: { isComplete: true, email: user.email, hidden: false }, + include: { + client: { + select: { id: true, name: true, number: true }, + }, + assignedTo: { + select: { id: true, name: true }, + }, + team: { + select: { id: true, name: true }, + }, }, - }, - }); + }); - reply.send({ - tickets: tickets, - sucess: true, - }); + reply.send({ + tickets: tickets, + sucess: true, + }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); // Get all tickets for an external user fastify.get( "/api/v1/tickets/user/external", - { - preHandler: requirePermission(["issue::read"]), - }, async (request: FastifyRequest, reply: FastifyReply) => { - const user = await checkSession(request); + try { + const user = await checkSession(request); - const tickets = await prisma.ticket.findMany({ - where: { email: user!.email, hidden: false }, - include: { - client: { - select: { id: true, name: true, number: true }, - }, - assignedTo: { - select: { id: true, name: true }, - }, - team: { - select: { id: true, name: true }, + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const tickets = await prisma.ticket.findMany({ + where: { email: user.email, hidden: false }, + include: { + client: { + select: { id: true, name: true, number: true }, + }, + assignedTo: { + select: { id: true, name: true }, + }, + team: { + select: { id: true, name: true }, + }, }, - }, - }); + }); - reply.send({ - tickets: tickets, - sucess: true, - }); + reply.send({ + tickets: tickets, + sucess: true, + }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); + } } ); @@ -1017,41 +1233,49 @@ export function ticketRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["issue::read"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const { id }: any = request.params; - - const user = await checkSession(request); - - if (id) { - const ticket = await prisma.ticket.findUnique({ - where: { id: id }, - }); + try { + const { id }: any = request.params; - const following = ticket?.following as string[]; + const user = await checkSession(request); - if (following.includes(user!.id)) { - reply.send({ - success: false, - message: "You are already following this issue", - }); + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); } - if (ticket) { - await prisma.ticket.update({ + if (id) { + const ticket = await prisma.ticket.findUnique({ where: { id: id }, - data: { - following: [...following, user!.id], - }, }); - } else { - reply.status(400).send({ - success: false, - message: "No ticket ID provided", + + const following = ticket?.following as string[]; + + if (following.includes(user.id)) { + reply.send({ + success: false, + message: "You are already following this issue", + }); + } + + if (ticket) { + await prisma.ticket.update({ + where: { id: id }, + data: { + following: [...following, user.id], + }, + }); + } else { + reply.status(400).send({ + success: false, + message: "No ticket ID provided", + }); + } + + reply.send({ + success: true, }); } - - reply.send({ - success: true, - }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); } } ); @@ -1063,40 +1287,48 @@ export function ticketRoutes(fastify: FastifyInstance) { preHandler: requirePermission(["issue::read"]), }, async (request: FastifyRequest, reply: FastifyReply) => { - const { id }: any = request.params; - const user = await checkSession(request); - - if (id) { - const ticket = await prisma.ticket.findUnique({ - where: { id: id }, - }); - - const following = ticket?.following as string[]; + try { + const { id }: any = request.params; + const user = await checkSession(request); - if (!following.includes(user!.id)) { - return reply.send({ - success: false, - message: "You are not following this issue", - }); + if (!user) { + return reply.code(401).send({ error: "Unauthorized" }); } - if (ticket) { - await prisma.ticket.update({ + if (id) { + const ticket = await prisma.ticket.findUnique({ where: { id: id }, - data: { - following: following.filter((userId) => userId !== user!.id), - }, }); - } else { - return reply.status(400).send({ - success: false, - message: "No ticket ID provided", + + const following = ticket?.following as string[]; + + if (!following.includes(user.id)) { + return reply.send({ + success: false, + message: "You are not following this issue", + }); + } + + if (ticket) { + await prisma.ticket.update({ + where: { id: id }, + data: { + following: following.filter((userId) => userId !== user.id), + }, + }); + } else { + return reply.status(400).send({ + success: false, + message: "No ticket ID provided", + }); + } + + reply.send({ + success: true, }); } - - reply.send({ - success: true, - }); + } catch (error) { + reply.code(401).send({ error: "Unauthorized" }); } } ); diff --git a/apps/api/src/controllers/users.ts b/apps/api/src/controllers/users.ts index 2caf586e9..71b9f82a3 100644 --- a/apps/api/src/controllers/users.ts +++ b/apps/api/src/controllers/users.ts @@ -41,9 +41,13 @@ export function userRoutes(fastify: FastifyInstance) { "/api/v1/user/new", async (request: FastifyRequest, reply: FastifyReply) => { - const session = await checkSession(request); + try { + const session = await checkSession(request); + + if (!session || !session.isAdmin) { + return reply.status(403).send({ message: "Unauthorized", failed: true }); + } - if (session!.isAdmin) { const { email, password, name, admin }: any = request.body; const e = email.toLowerCase(); @@ -71,7 +75,7 @@ export function userRoutes(fastify: FastifyInstance) { reply.send({ success: true, }); - } else { + } catch (error) { reply.status(403).send({ message: "Unauthorized", failed: true }); } } @@ -81,11 +85,15 @@ export function userRoutes(fastify: FastifyInstance) { fastify.put( "/api/v1/user/reset-password", async (request: FastifyRequest, reply: FastifyReply) => { - const { password, id }: any = request.body; + try { + const { password, id }: any = request.body; - const session = await checkSession(request); + const session = await checkSession(request); + + if (!session || !session.isAdmin) { + return reply.status(403).send({ message: "Unauthorized", failed: true }); + } - if (session!.isAdmin) { const hashedPass = await bcrypt.hash(password, 10); await prisma.user.update({ where: { id: id }, @@ -96,7 +104,7 @@ export function userRoutes(fastify: FastifyInstance) { reply .status(201) .send({ message: "password updated success", failed: false }); - } else { + } catch (error) { reply.status(403).send({ message: "Unauthorized", failed: true }); } } diff --git a/apps/api/src/lib/nodemailer/ticket/assigned.ts b/apps/api/src/lib/nodemailer/ticket/assigned.ts index b80e61f04..1202bca7f 100644 --- a/apps/api/src/lib/nodemailer/ticket/assigned.ts +++ b/apps/api/src/lib/nodemailer/ticket/assigned.ts @@ -2,9 +2,8 @@ import handlebars from "handlebars"; import { prisma } from "../../../prisma"; import { createTransportProvider } from "../transport"; -export async function sendAssignedEmail(email: any) { +export async function sendAssignedEmail(email: string, ticket?: any) { try { - const provider = await prisma.email.findFirst(); if (provider) { @@ -18,23 +17,36 @@ export async function sendAssignedEmail(email: any) { }, }); - var template = handlebars.compile(testhtml?.html); - var htmlToSend = template({}); // Pass an empty object as the argument to the template function + const ticketNumber = ticket?.Number ? `#${ticket.Number}` : ""; + const ticketTitle = ticket?.title || "a ticket"; + const fallbackHtml = `

Hello there, ticket ${ticketNumber} ${ticketTitle} has been assigned to you.

`; + const template = handlebars.compile(testhtml?.html || fallbackHtml); + const htmlToSend = template({ + id: ticket?.id, + number: ticket?.Number, + title: ticket?.title, + }); await mail .sendMail({ - from: provider?.reply, - to: email, - subject: `A new ticket has been assigned to you`, - text: `Hello there, a ticket has been assigned to you`, + from: provider?.reply, + to: email, + subject: ticket?.Number + ? `Ticket #${ticket.Number} has been assigned to you` + : `A new ticket has been assigned to you`, + text: ticket?.Number + ? `Hello there, ticket #${ticket.Number} (${ticketTitle}) has been assigned to you.` + : `Hello there, a ticket has been assigned to you.`, html: htmlToSend, }) .then((info: any) => { console.log("Message sent: %s", info.messageId); }) - .catch((err: any) => console.log(err)); + .catch((err: any) => + console.error("Failed to send assigned ticket email:", err) + ); } } catch (error) { - console.log(error); + console.error("Failed to prepare assigned ticket email:", error); } } diff --git a/apps/api/src/lib/nodemailer/ticket/status.ts b/apps/api/src/lib/nodemailer/ticket/status.ts index f29a8c77d..6f4ad90c9 100644 --- a/apps/api/src/lib/nodemailer/ticket/status.ts +++ b/apps/api/src/lib/nodemailer/ticket/status.ts @@ -2,40 +2,62 @@ import handlebars from "handlebars"; import { prisma } from "../../../prisma"; import { createTransportProvider } from "../transport"; +function getStatusLabel(ticket: any) { + return ticket.isComplete ? "Closed" : "Open"; +} + export async function sendTicketStatus(ticket: any) { - const email = await prisma.email.findFirst(); - - if (email) { - const transport = await createTransportProvider(); - - const testhtml = await prisma.emailTemplate.findFirst({ - where: { - type: "ticket_status_changed", - }, - }); - - var template = handlebars.compile(testhtml?.html); - var replacements = { - title: ticket.title, - status: ticket.isComplete ? "COMPLETED" : "OUTSTANDING", - }; - var htmlToSend = template(replacements); - - await transport - .sendMail({ - from: email?.reply, - to: ticket.email, - subject: `Issue #${ticket.Number} status is now ${ - ticket.isComplete ? "COMPLETED" : "OUTSTANDING" - }`, - text: `Hello there, Issue #${ticket.Number}, now has a status of ${ - ticket.isComplete ? "COMPLETED" : "OUTSTANDING" - }`, - html: htmlToSend, - }) - .then((info: any) => { - console.log("Message sent: %s", info.messageId); - }) - .catch((err: any) => console.log(err)); + try { + const email = await prisma.email.findFirst(); + + if (email) { + const transport = await createTransportProvider(); + + const recipients = [ + ticket.email, + ticket.assignedTo?.email, + ].filter(Boolean); + + const uniqueRecipients = [...new Set(recipients)]; + + if (uniqueRecipients.length === 0) { + console.log("No recipients found for ticket status email."); + return; + } + + const status = getStatusLabel(ticket); + const testhtml = await prisma.emailTemplate.findFirst({ + where: { + type: "ticket_status_changed", + }, + }); + + const fallbackHtml = `

Hello there, ticket #${ticket.Number} (${ticket.title}) is now ${status}.

`; + const template = handlebars.compile(testhtml?.html || fallbackHtml); + const replacements = { + id: ticket.id, + number: ticket.Number, + title: ticket.title, + status, + }; + const htmlToSend = template(replacements); + + await transport + .sendMail({ + from: email?.reply, + to: uniqueRecipients, + subject: `Ticket #${ticket.Number} is now ${status}`, + text: `Hello there, ticket #${ticket.Number} (${ticket.title}) is now ${status}.`, + html: htmlToSend, + }) + .then((info: any) => { + console.log("Message sent: %s", info.messageId); + }) + .catch((err: any) => + console.error("Failed to send ticket status email:", err) + ); + } + } catch (error) { + console.error("Failed to prepare ticket status email:", error); } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 22e14fc0b..3d6414d36 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -60,15 +60,28 @@ server.get( // JWT authentication hook server.addHook("preHandler", async function (request: any, reply: any) { try { - if (request.url === "/api/v1/auth/login" && request.method === "POST") { - return true; - } + const publicRoutes = [ + { method: "POST", url: "/api/v1/auth/login" }, + { method: "GET", url: "/api/v1/auth/check" }, + { method: "POST", url: "/api/v1/auth/user/register/external" }, + { method: "POST", url: "/api/v1/auth/password-reset" }, + { method: "POST", url: "/api/v1/auth/password-reset/code" }, + { method: "POST", url: "/api/v1/auth/password-reset/password" }, + { method: "GET", url: "/api/v1/auth/oidc/callback" }, + { method: "GET", url: "/api/v1/auth/oauth/callback" }, + { method: "POST", url: "/api/v1/ticket/public/create" }, + ]; + + const requestPath = request.url.split("?")[0]; + if ( - request.url === "/api/v1/ticket/public/create" && - request.method === "POST" + publicRoutes.some( + (route) => route.method === request.method && route.url === requestPath + ) ) { return true; } + const bearer = request.headers.authorization!.split(" ")[1]; checkToken(bearer); } catch (err) { @@ -82,35 +95,37 @@ server.addHook("preHandler", async function (request: any, reply: any) { const start = async () => { try { // Run prisma generate and migrate commands before starting the server - await new Promise((resolve, reject) => { - exec("npx prisma migrate deploy", (err, stdout, stderr) => { - if (err) { - console.error(err); - reject(err); - } - console.log(stdout); - console.error(stderr); - - exec("npx prisma generate", (err, stdout, stderr) => { + if (process.env.SKIP_DB_SETUP !== "1") { + await new Promise((resolve, reject) => { + exec("npx prisma migrate deploy", (err, stdout, stderr) => { if (err) { console.error(err); reject(err); } console.log(stdout); console.error(stderr); - }); - exec("npx prisma db seed", (err, stdout, stderr) => { - if (err) { - console.error(err); - reject(err); - } - console.log(stdout); - console.error(stderr); - resolve(); + exec("npx prisma generate", (err, stdout, stderr) => { + if (err) { + console.error(err); + reject(err); + } + console.log(stdout); + console.error(stderr); + }); + + exec("npx prisma db seed", (err, stdout, stderr) => { + if (err) { + console.error(err); + reject(err); + } + console.log(stdout); + console.error(stderr); + resolve(); + }); }); }); - }); + } // connect to database await prisma.$connect(); diff --git a/apps/api/src/prisma/migrations/20260422123000_revflow_custom_fields/migration.sql b/apps/api/src/prisma/migrations/20260422123000_revflow_custom_fields/migration.sql new file mode 100644 index 000000000..e8b48a609 --- /dev/null +++ b/apps/api/src/prisma/migrations/20260422123000_revflow_custom_fields/migration.sql @@ -0,0 +1,8 @@ +ALTER TYPE "TicketType" ADD VALUE 'revflow'; + +ALTER TABLE "Ticket" +ADD COLUMN "custom_field_1" TEXT, +ADD COLUMN "custom_field_2" TEXT, +ADD COLUMN "custom_field_3" TEXT, +ADD COLUMN "outcome" TEXT, +ADD COLUMN "customer_field" TEXT; diff --git a/apps/api/src/prisma/migrations/20260504120000_sync_ticket_closed_status/migration.sql b/apps/api/src/prisma/migrations/20260504120000_sync_ticket_closed_status/migration.sql new file mode 100644 index 000000000..18aa015c7 --- /dev/null +++ b/apps/api/src/prisma/migrations/20260504120000_sync_ticket_closed_status/migration.sql @@ -0,0 +1,3 @@ +-- No-op migration. +-- This directory existed without a migration.sql file, which prevents +-- `prisma migrate deploy` from starting the API. diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 040ed6460..9cecb92ff 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -139,6 +139,11 @@ model Ticket { createdBy Json? locked Boolean @default(false) following Json? + custom_field_1 String? + custom_field_2 String? + custom_field_3 String? + outcome String? + customer_field String? TicketFile TicketFile[] Comment Comment[] @@ -433,6 +438,7 @@ enum TicketType { maintenance access feedback + revflow } enum Template { diff --git a/apps/client/@/shadcn/components/app-sidebar.tsx b/apps/client/@/shadcn/components/app-sidebar.tsx index 3f9c97e13..40f6674b3 100644 --- a/apps/client/@/shadcn/components/app-sidebar.tsx +++ b/apps/client/@/shadcn/components/app-sidebar.tsx @@ -50,7 +50,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const data = { teams: [ { - name: "Peppermint", + name: "YOTTABYTE", plan: `version: ${process.env.NEXT_PUBLIC_CLIENT_VERSION}`, }, ], @@ -167,11 +167,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {/* */}
-
- +
+
- Peppermint + YOTTABYTE version: {process.env.NEXT_PUBLIC_CLIENT_VERSION} diff --git a/apps/client/@/shadcn/components/command-menu.tsx b/apps/client/@/shadcn/components/command-menu.tsx index 33adb875a..65cf51abb 100644 --- a/apps/client/@/shadcn/components/command-menu.tsx +++ b/apps/client/@/shadcn/components/command-menu.tsx @@ -62,8 +62,11 @@ export function CommandMenu() { Authorization: `Bearer ${token}`, }, }); + if (!response.ok) { + return []; + } const data = await response.json(); - return data.tickets; + return Array.isArray(data.tickets) ? data.tickets : []; }, { enabled: open, // Only fetch when command menu is open @@ -78,8 +81,11 @@ export function CommandMenu() { Authorization: `Bearer ${token}`, }, }); + if (!response.ok) { + return []; + } const data = await response.json(); - return data.users; + return Array.isArray(data.users) ? data.users : []; }, { enabled: open, // Only fetch when command menu is open diff --git a/apps/client/@/shadcn/hooks/useTicketFilters.ts b/apps/client/@/shadcn/hooks/useTicketFilters.ts index e9cd84bcd..e6502b306 100644 --- a/apps/client/@/shadcn/hooks/useTicketFilters.ts +++ b/apps/client/@/shadcn/hooks/useTicketFilters.ts @@ -1,26 +1,51 @@ import { Ticket } from '@/shadcn/types/tickets'; import { useEffect, useState } from 'react'; -export function useTicketFilters(tickets: Ticket[] = []) { - const [selectedPriorities, setSelectedPriorities] = useState(() => { - const saved = localStorage.getItem("all_selectedPriorities"); - return saved ? JSON.parse(saved) : []; - }); +function readStoredList(key: string) { + if (typeof window === "undefined") { + return []; + } - const [selectedStatuses, setSelectedStatuses] = useState(() => { - const saved = localStorage.getItem("all_selectedStatuses"); - return saved ? JSON.parse(saved) : []; - }); + const saved = window.localStorage.getItem(key); + if (!saved) { + return []; + } - const [selectedAssignees, setSelectedAssignees] = useState(() => { - const saved = localStorage.getItem("all_selectedAssignees"); - return saved ? JSON.parse(saved) : []; - }); + try { + return JSON.parse(saved); + } catch (error) { + return []; + } +} + +export function useTicketFilters(tickets: Ticket[] = []) { + const [selectedPriorities, setSelectedPriorities] = useState([]); + const [selectedStatuses, setSelectedStatuses] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState([]); useEffect(() => { - localStorage.setItem("all_selectedPriorities", JSON.stringify(selectedPriorities)); - localStorage.setItem("all_selectedStatuses", JSON.stringify(selectedStatuses)); - localStorage.setItem("all_selectedAssignees", JSON.stringify(selectedAssignees)); + setSelectedPriorities(readStoredList("all_selectedPriorities")); + setSelectedStatuses(readStoredList("all_selectedStatuses")); + setSelectedAssignees(readStoredList("all_selectedAssignees")); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem( + "all_selectedPriorities", + JSON.stringify(selectedPriorities) + ); + window.localStorage.setItem( + "all_selectedStatuses", + JSON.stringify(selectedStatuses) + ); + window.localStorage.setItem( + "all_selectedAssignees", + JSON.stringify(selectedAssignees) + ); }, [selectedPriorities, selectedStatuses, selectedAssignees]); const handlePriorityToggle = (priority: string) => { diff --git a/apps/client/@/shadcn/hooks/useTicketView.ts b/apps/client/@/shadcn/hooks/useTicketView.ts index d39f4c9f8..8c8708bed 100644 --- a/apps/client/@/shadcn/hooks/useTicketView.ts +++ b/apps/client/@/shadcn/hooks/useTicketView.ts @@ -1,38 +1,59 @@ import { KanbanGrouping, SortOption, Ticket, UISettings, ViewMode } from '@/shadcn/types/tickets'; import { useEffect, useState } from 'react'; +function readStoredValue(key: string, fallback: T): T { + if (typeof window === "undefined") { + return fallback; + } + + const saved = window.localStorage.getItem(key); + if (!saved) { + return fallback; + } + + if (typeof fallback === "string") { + return saved as T; + } + + try { + return JSON.parse(saved); + } catch (error) { + return fallback; + } +} + export function useTicketView(tickets: Ticket[] = []) { - const [viewMode, setViewMode] = useState(() => { - const saved = localStorage.getItem("preferred_view_mode"); - return (saved as ViewMode) || 'list'; - }); + const [viewMode, setViewMode] = useState(() => + readStoredValue("preferred_view_mode", "list") + ); - const [kanbanGrouping, setKanbanGrouping] = useState(() => { - const saved = localStorage.getItem("preferred_kanban_grouping"); - return (saved as KanbanGrouping) || 'status'; - }); + const [kanbanGrouping, setKanbanGrouping] = useState(() => + readStoredValue("preferred_kanban_grouping", "status") + ); - const [sortBy, setSortBy] = useState(() => { - const saved = localStorage.getItem("preferred_sort_by"); - return (saved as SortOption) || 'newest'; - }); + const [sortBy, setSortBy] = useState(() => + readStoredValue("preferred_sort_by", "newest") + ); - const [uiSettings, setUISettings] = useState(() => { - const saved = localStorage.getItem("preferred_ui_settings"); - return saved ? JSON.parse(saved) : { + const [uiSettings, setUISettings] = useState(() => + readStoredValue("preferred_ui_settings", { showAvatars: true, showDates: true, showPriority: true, showType: true, showTicketNumbers: true, - }; - }); + }) + ); useEffect(() => { - localStorage.setItem("preferred_view_mode", viewMode); - localStorage.setItem("preferred_kanban_grouping", kanbanGrouping); - localStorage.setItem("preferred_sort_by", sortBy); - localStorage.setItem("preferred_ui_settings", JSON.stringify(uiSettings)); + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem("preferred_view_mode", viewMode); + window.localStorage.setItem("preferred_kanban_grouping", kanbanGrouping); + window.localStorage.setItem("preferred_sort_by", sortBy); + window.localStorage.setItem("preferred_ui_settings", JSON.stringify(uiSettings)); }, [viewMode, kanbanGrouping, sortBy, uiSettings]); const handleUISettingChange = (setting: keyof UISettings, value: boolean) => { diff --git a/apps/client/apps/client/components/BlockEditor/index.tsx b/apps/client/apps/client/components/BlockEditor/index.tsx new file mode 100644 index 000000000..d76d65169 --- /dev/null +++ b/apps/client/apps/client/components/BlockEditor/index.tsx @@ -0,0 +1,22 @@ +import { useCreateBlockNote } from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; + + +import "@blocknote/core/fonts/inter.css"; +import "@blocknote/mantine/style.css"; + +export default function BlockNoteEditor({ setIssue }) { + const editor = useCreateBlockNote(); + + return ( + { + setIssue(editor.document); + }} + /> + ); +} diff --git a/apps/client/apps/client/components/NotebookEditor/index.tsx b/apps/client/apps/client/components/NotebookEditor/index.tsx new file mode 100644 index 000000000..ef262c8a7 --- /dev/null +++ b/apps/client/apps/client/components/NotebookEditor/index.tsx @@ -0,0 +1,217 @@ +//@ts-nocheck +import { toast } from "@/shadcn/hooks/use-toast"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shadcn/ui/dropdown-menu"; +import { BlockNoteEditor, PartialBlock } from "@blocknote/core"; +import { BlockNoteView } from "@blocknote/mantine"; +import { getCookie } from "cookies-next"; +import { Ellipsis } from "lucide-react"; +import moment from "moment"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; +import { useDebounce } from "use-debounce"; +import { useUser } from "../../store/session"; + +function isHTML(str) { + var a = document.createElement("div"); + a.innerHTML = str; + + for (var c = a.childNodes, i = c.length; i--; ) { + if (c[i].nodeType == 1) return true; + } + + return false; +} + +export default function NotebookEditor() { + const router = useRouter(); + const token = getCookie("session"); + + const user = useUser(); + + const [initialContent, setInitialContent] = useState< + PartialBlock[] | undefined | "loading" + >("loading"); + + const editor = useMemo(() => { + if (initialContent === "loading") { + return undefined; + } + return BlockNoteEditor.create({ initialContent }); + }, [initialContent]); + + const [value, setValue] = useState(); + const [note, setNote] = useState(); + const [title, setTitle] = useState(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(); + + const [debouncedValue] = useDebounce(value, 500); + const [debounceTitle] = useDebounce(title, 500); + + async function fetchNotebook() { + setValue(undefined); + setLoading(true); + const res = await fetch(`/api/v1/notebooks/note/${router.query.id}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json()); + if (res.note.userId !== user.user.id) { + router.back(); + } + await loadFromStorage(res.note.note).then((content) => { + setInitialContent(content); + }); + setNote(res.note); + setTitle(res.note.title); + setLoading(false); + } + + async function updateNoteBook() { + setSaving(true); + const res = await fetch(`/api/v1/notebooks/note/${router.query.id}/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + title: debounceTitle, + content: JSON.stringify(debouncedValue), + }), + }); + const data = await res.json(); + setSaving(false); + let date = new Date(); + // @ts-ignore + setLastSaved(new Date(date).getTime()); + if(data.status) { + toast({ + variant: "destructive", + title: "Error -> Unable to update", + description: data.message, + }); + } + } + + async function deleteNotebook(id) { + if (window.confirm("Do you really want to delete this notebook?")) { + await fetch(`/api/v1/notebooks/note/${router.query.id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .then((res) => res.json()) + .then((res) => { + if (res.success) { + router.push("/documents"); + } else { + toast({ + variant: "destructive", + title: "Error -> Unable to delete", + description: res.message, + }); + } + }); + } + } + + useEffect(() => { + fetchNotebook(); + }, [router]); + + useEffect(() => { + if (debouncedValue || debounceTitle) { + updateNoteBook(); + } + }, [debouncedValue, debounceTitle]); + + async function loadFromStorage(val) { + const storageString = val; + + if (isHTML(storageString)) { + return undefined; + } else { + return storageString + ? (JSON.parse(storageString) as PartialBlock[]) + : undefined; + } + } + + async function convertHTML() { + //@ts-expect-error + const blocks = await editor.tryParseHTMLToBlocks(note?.note); + editor.replaceBlocks(editor.document, blocks); + } + + useEffect(() => { + if (initialContent === undefined) { + convertHTML(); + } + }, [initialContent]); + + if (editor === undefined) { + return "Loading content..."; + } + + const handleInputChange = (editor) => { + setValue(editor.document); + }; + + return ( + <> +
+ {saving ? ( + saving .... + ) : ( + + last saved: {moment(lastSaved).format("hh:mm:ss")} + + )} + + + + + + deleteNotebook()} + > + Delete + + + +
+ {!loading && ( +
+
+
+ setTitle(e.target.value)} + className="text-3xl px-0 font-bold w-full border-none bg-transparent outline-none focus:ring-0 focus:outline-none" + /> +
+ + +
+
+ )} + + ); +} diff --git a/apps/client/apps/client/components/TicketDetails/index.tsx b/apps/client/apps/client/components/TicketDetails/index.tsx new file mode 100644 index 000000000..79bf9ce31 --- /dev/null +++ b/apps/client/apps/client/components/TicketDetails/index.tsx @@ -0,0 +1,1506 @@ +// @ts-nocheck +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/shadcn/ui/command"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/shadcn/ui/context-menu"; +import { BlockNoteEditor, PartialBlock } from "@blocknote/core"; +import { BlockNoteView } from "@blocknote/mantine"; +import { CheckCircleIcon } from "@heroicons/react/20/solid"; +import { getCookie } from "cookies-next"; +import moment from "moment"; +import useTranslation from "next-translate/useTranslation"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import Frame from "react-frame-component"; +import { useQuery } from "react-query"; +import { useDebounce } from "use-debounce"; + +import { toast } from "@/shadcn/hooks/use-toast"; +import { hasAccess } from "@/shadcn/lib/hasAccess"; +import { cn } from "@/shadcn/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/ui/avatar"; +import { Button } from "@/shadcn/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shadcn/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shadcn/ui/popover"; +import { Switch } from "@/shadcn/ui/switch"; +import { + CheckIcon, + CircleCheck, + CircleDotDashed, + Ellipsis, + Eye, + EyeOff, + LifeBuoy, + Loader, + LoaderCircle, + Lock, + PanelTopClose, + SignalHigh, + SignalLow, + SignalMedium, + Trash2, + Unlock, +} from "lucide-react"; +import { useUser } from "../../store/session"; +import { ClientCombo, IconCombo, UserCombo } from "../Combo"; + +const ticketStatusMap = [ + { id: 0, value: "hold", name: "Hold", icon: CircleDotDashed }, + { id: 1, value: "needs_support", name: "Needs Support", icon: LifeBuoy }, + { id: 2, value: "in_progress", name: "In Progress", icon: CircleDotDashed }, + { id: 3, value: "in_review", name: "In Review", icon: Loader }, + { id: 4, value: "done", name: "Done", icon: CircleCheck }, +]; + +const priorityOptions = [ + { + id: "1", + name: "Low", + value: "low", + icon: SignalLow, + }, + { + id: "2", + name: "Medium", + value: "medium", + icon: SignalMedium, + }, + { + id: "1", + name: "High", + value: "high", + icon: SignalHigh, + }, +]; + +export default function Ticket() { + const router = useRouter(); + const { t } = useTranslation("peppermint"); + + const token = getCookie("session"); + + const { user } = useUser(); + + const fetchTicketById = async () => { + const id = router.query.id; + const res = await fetch(`/api/v1/ticket/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + hasAccess(res); + + return res.json(); + }; + + const { data, status, refetch } = useQuery("fetchTickets", fetchTicketById, { + enabled: false, + }); + + useEffect(() => { + refetch(); + }, [router]); + + const [initialContent, setInitialContent] = useState< + PartialBlock[] | undefined | "loading" + >("loading"); + + const editor = useMemo(() => { + if (initialContent === "loading") { + return undefined; + } + return BlockNoteEditor.create({ initialContent }); + }, [initialContent]); + + const [edit, setEdit] = useState(false); + const [editTime, setTimeEdit] = useState(false); + const [assignedEdit, setAssignedEdit] = useState(false); + const [labelEdit, setLabelEdit] = useState(false); + + const [users, setUsers] = useState(); + const [clients, setClients] = useState(); + const [n, setN] = useState(); + + const [note, setNote] = useState(); + const [issue, setIssue] = useState(); + const [title, setTitle] = useState(); + // const [uploaded, setUploaded] = useState(); + const [priority, setPriority] = useState(); + const [ticketStatus, setTicketStatus] = useState(); + const [comment, setComment] = useState(); + const [timeSpent, setTimeSpent] = useState(); + const [publicComment, setPublicComment] = useState(false); + const [timeReason, setTimeReason] = useState(""); + const [file, setFile] = useState(null); + const [assignedClient, setAssignedClient] = useState(); + + const history = useRouter(); + + const { id } = history.query; + + async function update() { + if (data && data.ticket && data.ticket.locked) return; + + const res = await fetch(`/api/v1/ticket/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id, + detail: JSON.stringify(debouncedValue), + note, + title: debounceTitle, + priority: priority?.value, + status: ticketStatus?.value, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to update ticket", + }); + return; + } + setEdit(false); + } + + async function updateStatus() { + if (data && data.ticket && data.ticket.locked) return; + + const res = await fetch(`/api/v1/ticket/status/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + status: !data.ticket.isComplete, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to update status", + }); + return; + } + refetch(); + } + + async function hide(hidden) { + if (data && data.ticket && data.ticket.locked) return; + + const res = await fetch(`/api/v1/ticket/status/hide`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + hidden, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to update visibility", + }); + return; + } + refetch(); + } + + async function lock(locked) { + const res = await fetch(`/api/v1/ticket/status/lock`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + locked, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to update lock status", + }); + return; + } + refetch(); + } + + async function deleteIssue() { + await fetch(`/api/v1/ticket/delete`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.success) { + toast({ + variant: "default", + title: "Issue Deleted", + description: "The issue has been deleted", + }); + router.push("/issues"); + } + }); + } + + async function addComment() { + if (data && data.ticket && data.ticket.locked) return; + + const res = await fetch(`/api/v1/ticket/comment`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + text: comment, + id, + public: publicComment, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to add comment", + }); + return; + } + refetch(); + } + + async function deleteComment(id: string) { + await fetch(`/api/v1/ticket/comment/delete`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ id }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.success) { + refetch(); + } else { + toast({ + variant: "destructive", + title: "Error", + description: "Failed to delete comment", + }); + } + }); + } + + async function addTime() { + if (data && data.ticket && data.ticket.locked) return; + + await fetch(`/api/v1/time/new`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + time: timeSpent, + ticket: id, + title: timeReason, + user: user.id, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.success) { + setTimeEdit(false); + refetch(); + toast({ + variant: "default", + title: "Time Added", + description: "Time has been added to the ticket", + }); + } + }); + } + + async function fetchUsers() { + const res = await fetch(`/api/v1/users/all`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to fetch users", + }); + return; + } + + if (res.users) { + setUsers(res.users); + } + } + + async function fetchClients() { + const res = await fetch(`/api/v1/clients/all`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to fetch clients", + }); + return; + } + + console.log(res); + + if (res.clients) { + setClients(res.clients); + } + } + + async function subscribe() { + if (data && data.ticket && data.ticket.locked) return; + + const isFollowing = data.ticket.following?.includes(user.id); + const action = isFollowing ? "unsubscribe" : "subscribe"; + + const res = await fetch(`/api/v1/ticket/${action}/${id}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || `Failed to ${action} to issue`, + }); + return; + } + + toast({ + title: isFollowing ? "Unsubscribed" : "Subscribed", + description: isFollowing + ? "You will no longer receive updates" + : "You will now receive updates", + duration: 3000, + }); + + refetch(); + } + + async function transferTicket() { + if (data && data.ticket && data.ticket.locked) return; + if (n === undefined) return; + + const res = await fetch(`/api/v1/ticket/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: n ? n.id : undefined, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to transfer ticket", + }); + return; + } + + setAssignedEdit(false); + refetch(); + } + + async function transferClient() { + if (data && data.ticket && data.ticket.locked) return; + if (assignedClient === undefined) return; + + const res = await fetch(`/api/v1/ticket/transfer/client`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + client: assignedClient ? assignedClient.id : undefined, + id, + }), + }).then((res) => res.json()); + + if (!res.success) { + toast({ + variant: "destructive", + title: "Error", + description: res.message || "Failed to transfer client", + }); + return; + } + + setAssignedEdit(false); + refetch(); + } + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files) { + setFile(e.target.files[0]); + } + }; + + const handleUpload = async () => { + if (file) { + const formData = new FormData(); + formData.append("file", file); + formData.append("user", user.id); + + try { + // You can write the URL of your server or any other endpoint used for file upload + const result = await fetch( + `/api/v1/storage/ticket/${router.query.id}/upload/single`, + { + method: "POST", + body: formData, + } + ); + + const data = await result.json(); + + if (data.success) { + setFile(null); + refetch(); + } + } catch (error) { + console.error(error); + } + } + }; + + const fileInputRef = useRef(null); + + const handleButtonClick = () => { + fileInputRef.current.click(); + }; + + useEffect(() => { + handleUpload(); + }, [file]); + + useEffect(() => { + fetchUsers(); + fetchClients(); + }, []); + + useEffect(() => { + transferTicket(); + }, [n]); + + useEffect(() => { + transferClient(); + }, [assignedClient]); + + const [debouncedValue] = useDebounce(issue, 500); + const [debounceTitle] = useDebounce(title, 500); + + useEffect(() => { + update(); + }, [priority, ticketStatus, debounceTitle]); + + useEffect(() => { + if (issue) { + update(); + } + }, [debouncedValue]); + + async function loadFromStorage() { + const storageString = data.ticket.detail as PartialBlock[]; + // if (storageString && isJsonString(storageString)) { + // return JSON.parse(storageString) as PartialBlock[] + // } else { + // return undefined; + // } + try { + // @ts-ignore + return JSON.parse(storageString) as PartialBlock[]; + } catch (e) { + return undefined; + } + } + + async function convertHTML() { + const blocks = (await editor.tryParseHTMLToBlocks( + data.ticket.detail + )) as PartialBlock[]; + editor.replaceBlocks(editor.document, blocks); + } + + // Loads the previously stored editor contents. + useEffect(() => { + if (status === "success" && data && data.ticket) { + loadFromStorage().then((content) => { + if (typeof content === "object") { + setInitialContent(content); + } else { + setInitialContent(undefined); + } + }); + } + }, [status, data]); + + useEffect(() => { + if (initialContent === undefined) { + convertHTML(); + } + }, [initialContent]); + + if (editor === undefined) { + return ( +
+ +
+ ); + } + + const handleInputChange = (editor) => { + if (data.ticket.locked) return; + setIssue(editor.document); + }; + + async function updateTicketStatus(e: any, ticket: any) { + await fetch(`/api/v1/ticket/status/update`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id, status: !ticket.isComplete }), + }) + .then((res) => res.json()) + .then(() => { + toast({ + title: ticket.isComplete ? "Issue re-opened" : "Issue closed", + description: "The status of the issue has been updated.", + duration: 3000, + }); + refetch(); + }); + } + + // Add these new functions + async function updateTicketAssignee(ticketId: string, user: any) { + try { + const response = await fetch(`/api/v1/ticket/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: user ? user.id : undefined, + id: ticketId, + }), + }); + + if (!response.ok) throw new Error("Failed to update assignee"); + + toast({ + title: "Assignee updated", + description: `Transferred issue successfully`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update assignee", + variant: "destructive", + duration: 3000, + }); + } + } + + async function updateTicketPriority(ticket: any, priority: string) { + try { + const response = await fetch(`/api/v1/ticket/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id: ticket.id, + detail: ticket.detail, + note: ticket.note, + title: ticket.title, + priority: priority, + status: ticket.status, + }), + }).then((res) => res.json()); + + if (!response.success) throw new Error("Failed to update priority"); + + toast({ + title: "Priority updated", + description: `Ticket priority set to ${priority}`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update priority", + variant: "destructive", + duration: 3000, + }); + } + } + + const priorities = ["low", "medium", "high"]; + + return ( +
+ {status === "loading" && ( +
+

Loading data ...

+ {/* */} +
+ )} + + {status === "error" && ( +
+

Error fetching data ...

+
+ )} + + {status === "success" && ( + + +
+
+
+
+
+
+

+ #{data.ticket.Number} - +

+ setTitle(e.target.value)} + key={data.ticket.id} + disabled={data.ticket.locked} + /> +
+
+
+ {data.ticket.client && ( +
+ + {data.ticket.client.name} + +
+ )} +
+ {!data.ticket.isComplete ? ( +
+ + {t("open_issue")} + +
+ ) : ( +
+ + {t("closed_issue")} + +
+ )} +
+
+ + {data.ticket.type} + +
+ {data.ticket.hidden && ( +
+ + Hidden + +
+ )} + {data.ticket.locked && ( +
+ + Locked + +
+ )} +
+ {user.isAdmin && ( + + + + + + + Issue Actions + + + {data.ticket.hidden ? ( + hide(false)} + > + + Show Issue + + ) : ( + hide(true)} + > + + Hide Issue + + )} + {data.ticket.locked ? ( + lock(false)} + > + + Unlock Issue + + ) : ( + lock(true)} + > + + Lock Issue + + )} + + deleteIssue()} + > + + Delete Issue + + + + )} +
+
+
+ +
+
+ {!data.ticket.fromImap ? ( + <> + + + ) : ( +
+
+ +
+
+ )} +
+
+
+
+
+ + Activity + + +
+ + + {data.ticket.following && + data.ticket.following.length > 0 && ( +
+ + + + + +
+ Followers + {data.ticket.following.map( + (follower: any) => { + const userMatch = users.find( + (user) => + user.id === follower && + user.id !== + data.ticket.assignedTo.id + ); + console.log(userMatch); + return userMatch ? ( +
+ {userMatch.name} +
+ ) : null; + } + )} + + {data.ticket.following.filter( + (follower: any) => + follower !== data.ticket.assignedTo.id + ).length === 0 && ( + + This issue has no followers + + )} +
+
+
+
+ )} +
+
+
+
+ {data.ticket.fromImap ? ( + <> + + {data.ticket.email} + + created via email at + + {moment(data.ticket.createdAt).format( + "DD/MM/YYYY" + )} + + + ) : ( + <> + {data.ticket.createdBy ? ( +
+ + Created by + + {data.ticket.createdBy.name} + {" "} + at{" "} + + + {moment(data.ticket.createdAt).format( + "LLL" + )} + + {data.ticket.name && ( + + for {data.ticket.name} + + )} + {data.ticket.email && ( + + ( {data.ticket.email} ) + + )} +
+ ) : ( +
+ Created at + + + {moment(data.ticket.createdAt).format( + "LLL" + )} + + {data.ticket.client && ( + + for{" "} + + {data.ticket.client.name} + + + )} + +
+ )} + + )} +
+
+
+
    + {data.ticket.comments.length > 0 && + data.ticket.comments.map((comment: any) => ( +
  • +
    + + + + {comment.user + ? comment.user.name.slice(0, 1) + : comment.replyEmail.slice(0, 1)} + + + + {comment.user + ? comment.user.name + : comment.replyEmail} + + + {moment(comment.createdAt).format("LLL")} + + {(user.isAdmin || + (comment.user && + comment.userId === user.id)) && ( + { + deleteComment(comment.id); + }} + /> + )} +
    + {comment.text} +
  • + ))} +
+
+
+
+
+
+
+ +