import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router"; import type { TFunction } from "i18next"; import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto"; import NotificationService from "~/modules/notifications/services/.server/NotificationService"; import { EntitiesApi } from "~/utils/api/.server/EntitiesApi"; import { RowCommentsApi } from "~/utils/api/.server/RowCommentsApi"; import { RowsApi } from "~/utils/api/.server/RowsApi"; import UrlUtils from "~/utils/app/UrlUtils"; import { setRowCommentReaction } from "~/utils/db/entities/rowCommentReaction.db.server"; import { getRowCommentInRow, updateRowCommentInRow } from "~/utils/db/entities/rowComments.db.server"; import { createRowTask, deleteRowTaskInRow, getRowTaskInRow, updateRowTaskInRow } from "~/utils/db/entities/rowTasks.db.server"; import { getUser } from "~/utils/db/users.db.server"; import EntityHelper from "~/utils/helpers/EntityHelper"; import { getEntityPermission } from "~/utils/helpers/PermissionsHelper"; import { verifyUserHasPermission } from "~/utils/helpers/.server/PermissionsService"; import RowHelper from "~/utils/helpers/RowHelper"; import RowsRequestUtils from "../utils/RowsRequestUtils"; import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker"; import FormulaService from "~/modules/formulas/services/.server/FormulaService"; import EventsService from "~/modules/events/services/.server/EventsService"; import { RowTasksCreatedDto } from "~/modules/events/dtos/RowTasksCreatedDto"; import { RowTasksUpdatedDto } from "~/modules/events/dtos/RowTasksUpdatedDto"; import { RowTasksDeletedDto } from "~/modules/events/dtos/RowTasksDeletedDto"; import { RowCommentsCreatedDto } from "~/modules/events/dtos/RowCommentsCreatedDto"; import { RowCommentsReactedDto } from "~/modules/events/dtos/RowCommentsReactedDto"; import { RowCommentsDeletedDto } from "~/modules/events/dtos/RowCommentsDeletedDto"; import { safeRedirectPathFromRequest } from "~/utils/app/RedirectSafety.server"; import i18next from "i18next"; import { translateEntityTitlePlural } from "~/utils/i18n/translateLabel"; import { getRowsRouteErrorCode, getRowsRouteErrorStatus, translateRowsRouteError } from "./Rows_Error.server"; export namespace Rows_Overview { export type LoaderData = { meta: MetaTagsDto; rowData: RowsApi.GetRowData; routes: EntitiesApi.Routes; relationshipRows: RowsApi.GetRelationshipRowsData; }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_Overview] ${params.entity}`); const { t, userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params }); await time(verifyUserHasPermission(request, getEntityPermission(entity, "read"), tenantId), "verifyUserHasPermission"); if (!entity.isAutogenerated || entity.type === "system") { throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404"); } const rowData = await time( RowsApi.get(params.id!, { entity, tenantId, userId, }), "RowsApi.get" ); await time( FormulaService.trigger({ trigger: "BEFORE_VIEWED", rows: [rowData.item], entity: rowData.entity, session: { tenantId, userId }, t }), "FormulaService.trigger.BEFORE_VIEWED" ); const entityPluralLabel = translateEntityTitlePlural(t, i18next, entity); const data: LoaderData = { meta: [{ title: `${RowHelper.getTextDescription({ entity, item: rowData.item, t })} | ${entityPluralLabel} | ${process.env.APP_NAME}` }], rowData, routes: EntitiesApi.getNoCodeRoutes({ request, params }), relationshipRows: await time(RowsApi.getRelationshipRows({ entity, tenantId, userId }), "RowsApi.getRelationshipRows"), }; return Response.json(data, { headers: getServerTimingHeader() }); }; export type ActionData = { updatedRow?: RowsApi.GetRowData; success?: string; error?: string; errorCode?: import("~/utils/api/.server/RowsApi").RowApiErrorCode; }; export const action: ActionFunction = async ({ request, params }) => { const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_Overview] ${params.entity}`); const { t, userId, tenantId, entity, form } = await RowsRequestUtils.getAction({ request, params }); const action = form.get("action")?.toString() ?? ""; const user = await time(getUser(userId), "getUser"); const rowData = await time( RowsApi.get(params.id!, { entity, tenantId, userId, }), "RowsApi.get" ); const { item } = rowData; if (action === "edit") { try { const rowValues = RowHelper.getRowPropertiesFromForm({ t, entity, form, existing: item }); await time( RowsApi.update(params.id!, { entity, request, tenantId, userId, rowValues, }), "RowsApi.update" ); } catch (error: unknown) { return rowActionError(t, error, getServerTimingHeader()); } // const redirectTo = form.get("redirect")?.toString() || new URL(request.url).searchParams.get("redirect")?.toString(); // if (redirectTo) { // return redirect(redirectTo, { headers: getServerTimingHeader() }); // } const updatedRow = await time(RowsApi.get(params.id!, { entity, tenantId, userId }), "RowsApi.get"); return Response.json({ updatedRow, success: t("shared.saved") }, { headers: getServerTimingHeader() }); } else if (action === "delete") { try { await time(verifyUserHasPermission(request, getEntityPermission(entity, "delete"), tenantId), "verifyUserHasPermission"); await time( RowsApi.del(params.id!, { entity, request, tenantId, userId, }), "RowsApi.del" ); if (item.createdByUser) { await time( NotificationService.send({ channel: "my-rows", to: item.createdByUser, tenantId, notification: { from: { user }, message: t("notifications.messages.row.deleted", { email: user?.email ?? t("shared.unknown"), row: RowHelper.getTextDescription({ entity, item, t }), }), action: { title: t("shared.view"), url: EntityHelper.getRoutes({ routes: EntitiesApi.getNoCodeRoutes({ request, params }), entity, item })?.overview ?? "", }, }, }), "NotificationService.send" ); } } catch (error: unknown) { return rowActionError(t, error, getServerTimingHeader()); } const redirectTo = form.get("redirect")?.toString() || new URL(request.url).searchParams.get("redirect")?.toString(); if (redirectTo) { return redirect(safeRedirectPathFromRequest(request, redirectTo), { headers: getServerTimingHeader() }); } const routes = EntitiesApi.getNoCodeRoutes({ request, params }); const listRoute = EntityHelper.getRoutes({ routes, entity })?.list; if (listRoute) { return redirect(listRoute, { headers: getServerTimingHeader() }); } return Response.json({ deleted: true }, { headers: getServerTimingHeader() }); } else if (action === "comment") { if (!rowData.rowPermissions.canComment) { return rowActionForbidden(t, getServerTimingHeader()); } let comment = form.get("comment")?.toString(); if (!comment) { return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() }); } await time( RowCommentsApi.create(item.id, { comment, tenantId, userId, }), "RowCommentsApi.create" ); await EventsService.create({ request, event: "row.comments.created", tenantId, userId, data: { rowId: item.id, comment: { id: item.id, text: comment }, user: !user ? null : { id: user.id, email: user.email }, } satisfies RowCommentsCreatedDto, }); if (item.createdByUser) { await time( NotificationService.send({ channel: "my-rows", to: item.createdByUser, tenantId, notification: { from: { user }, message: t("notifications.messages.row.commented", { email: user?.email ?? t("shared.unknown"), row: RowHelper.getTextDescription({ entity, item, t }), }), action: { title: t("shared.view"), url: EntityHelper.getRoutes({ routes: EntitiesApi.getNoCodeRoutes({ request, params }), entity, item })?.overview ?? "", }, }, }), "NotificationService.send" ); } return Response.json({ newComment: comment }, { headers: getServerTimingHeader() }); } else if (action === "comment-reaction") { if (!rowData.rowPermissions.canComment) { return rowActionForbidden(t, getServerTimingHeader()); } const rowCommentId = form.get("comment-id")?.toString(); const reaction = form.get("reaction")?.toString(); if (!rowCommentId || !reaction) { return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() }); } const comment = await time(getRowCommentInRow({ id: rowCommentId, rowId: item.id }), "getRowCommentInRow"); if (!comment) { return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() }); } if (comment) { await time( setRowCommentReaction({ createdByUserId: userId, rowCommentId, reaction, }), "setRowCommentReaction" ); await EventsService.create({ request, event: "row.comments.reacted", tenantId, userId, data: { rowId: item.id, comment: { id: comment.id, text: comment.value }, reaction, user: !user ? null : { id: user.id, email: user.email }, } satisfies RowCommentsReactedDto, }); } return Response.json({ newCommentReaction: reaction }, { headers: getServerTimingHeader() }); } else if (action === "comment-delete") { const rowCommentId = form.get("comment-id")?.toString() ?? ""; const comment = await getRowCommentInRow({ id: rowCommentId, rowId: item.id }); if (!comment) { return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() }); } if (!rowData.rowPermissions.canUpdate && comment.createdByUserId !== userId) { return rowActionForbidden(t, getServerTimingHeader()); } await time(updateRowCommentInRow({ id: comment.id, rowId: item.id }, { isDeleted: true }), "updateRowCommentInRow"); await EventsService.create({ request, event: "row.comments.deleted", tenantId, userId, data: { rowId: item.id, comment: { id: comment.id, text: comment.value }, user: !user ? null : { id: user.id, email: user.email }, } satisfies RowCommentsDeletedDto, }); return Response.json({ deletedComment: rowCommentId }, { headers: getServerTimingHeader() }); } else if (action === "task-new") { if (!rowData.rowPermissions.canUpdate) { return rowActionForbidden(t, getServerTimingHeader()); } const taskTitle = form.get("task-title")?.toString(); if (!taskTitle) { return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() }); } const task = await time( createRowTask({ createdByUserId: userId, rowId: item.id, title: taskTitle, }), "createRowTask" ); await EventsService.create({ request, event: "row.tasks.created", tenantId, userId, data: { rowId: item.id, task: { id: task.id, name: task.title }, user: !user ? null : { id: user.id, email: user.email }, } satisfies RowTasksCreatedDto, }); return Response.json({ newTask: task }, { headers: getServerTimingHeader() }); } else if (action === "task-complete-toggle") { if (!rowData.rowPermissions.canUpdate) { return rowActionForbidden(t, getServerTimingHeader()); } const taskId = form.get("task-id")?.toString() ?? ""; const task = await time(getRowTaskInRow({ id: taskId, rowId: item.id }), "getRowTaskInRow"); if (!task) { return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() }); } if (task) { if (task.completed) { await time( updateRowTaskInRow( { id: taskId, rowId: item.id }, { completed: false, completedAt: null, completedByUserId: null, } ), "updateRowTask" ); } else { await time( updateRowTaskInRow( { id: taskId, rowId: item.id }, { completed: true, completedAt: new Date(), completedByUserId: userId, } ), "updateRowTask" ); } await EventsService.create({ request, event: "row.tasks.created", tenantId, userId, data: { rowId: item.id, old: { id: task.id, name: task.title, completedAt: task.completedAt }, new: { id: task.id, name: task.title, completedAt: task.completed ? new Date() : null }, user: !user ? null : { id: user.id, email: user.email }, } satisfies RowTasksUpdatedDto, }); } return Response.json({ completedTask: taskId }, { headers: getServerTimingHeader() }); } else if (action === "task-delete") { if (!rowData.rowPermissions.canUpdate) { return rowActionForbidden(t, getServerTimingHeader()); } const taskId = form.get("task-id")?.toString() ?? ""; const task = await time(getRowTaskInRow({ id: taskId, rowId: item.id }), "getRowTaskInRow"); if (!task) { return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() }); } if (task) { await time(deleteRowTaskInRow({ id: taskId, rowId: item.id }), "deleteRowTaskInRow"); await EventsService.create({ request, event: "row.tasks.deleted", tenantId, userId, data: { rowId: item.id, task: { id: task.id, name: task.title }, user: !user ? null : { id: user.id, email: user.email }, } satisfies RowTasksDeletedDto, }); } return Response.json({ deletedTask: taskId }, { headers: getServerTimingHeader() }); } else { return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() }); } }; } function rowActionForbidden(t: TFunction, headers: HeadersInit) { return Response.json({ error: t("foundation.errors.forbidden") }, { status: 403, headers }); } function rowActionError(t: TFunction, error: unknown, headers: HeadersInit) { return Response.json( { error: translateRowsRouteError(t, error), errorCode: getRowsRouteErrorCode(error) } satisfies Rows_Overview.ActionData, { status: getRowsRouteErrorStatus(error), headers } ); }