notification added in with subs :D
This commit is contained in:
29
backend/db/schema/notifications.schema.ts
Normal file
29
backend/db/schema/notifications.schema.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
boolean,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import type z from "zod";
|
||||
|
||||
export const notifications = pgTable(
|
||||
"notifications",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull(),
|
||||
active: boolean("active").default(false),
|
||||
interval: text("interval").default("5"),
|
||||
options: jsonb("options").default([]),
|
||||
},
|
||||
(table) => [uniqueIndex("notify_name").on(table.name)],
|
||||
);
|
||||
|
||||
export const notificationSchema = createSelectSchema(notifications);
|
||||
export const newNotificationSchema = createInsertSchema(notifications);
|
||||
|
||||
export type Notification = z.infer<typeof notificationSchema>;
|
||||
export type NewNotification = z.infer<typeof newNotificationSchema>;
|
||||
30
backend/db/schema/notifications.sub.schema.ts
Normal file
30
backend/db/schema/notifications.sub.schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, text, unique, uuid } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import type z from "zod";
|
||||
import { user } from "./auth.schema.js";
|
||||
import { notifications } from "./notifications.schema.js";
|
||||
|
||||
export const notificationSub = pgTable(
|
||||
"notification_sub",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
notificationId: uuid("notification_id")
|
||||
.notNull()
|
||||
.references(() => notifications.id, { onDelete: "cascade" }),
|
||||
emails: text("emails").array().default([]),
|
||||
},
|
||||
(table) => ({
|
||||
userNotificationUnique: unique(
|
||||
"notification_sub_user_notification_unique",
|
||||
).on(table.userId, table.notificationId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const notificationSubSchema = createSelectSchema(notificationSub);
|
||||
export const newNotificationSubSchema = createInsertSchema(notificationSub);
|
||||
|
||||
export type NotificationSub = z.infer<typeof notificationSubSchema>;
|
||||
export type NewNotificationSub = z.infer<typeof newNotificationSubSchema>;
|
||||
52
backend/middleware/auth.requiredPerms.middleware.ts
Normal file
52
backend/middleware/auth.requiredPerms.middleware.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { auth } from "../utils/auth.utils.js";
|
||||
|
||||
type PermissionMap = Record<string, string[]>;
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
authz?: {
|
||||
success: boolean;
|
||||
permissions: PermissionMap;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoles(roles: unknown): string {
|
||||
if (Array.isArray(roles)) return roles.join(",");
|
||||
if (typeof roles === "string") return roles;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function requirePermission(permissions: PermissionMap) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const role = normalizeRoles(req.user?.roles) as any;
|
||||
|
||||
const result = await auth.api.userHasPermission({
|
||||
body: {
|
||||
role,
|
||||
permissions,
|
||||
},
|
||||
});
|
||||
|
||||
req.authz = {
|
||||
success: !!result?.success,
|
||||
permissions,
|
||||
};
|
||||
|
||||
if (!result?.success) {
|
||||
return res.status(403).json({
|
||||
ok: false,
|
||||
message: "You do not have permission to perform this action.",
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
153
backend/notification/notification.controller.ts
Normal file
153
backend/notification/notification.controller.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { notificationSub } from "db/schema/notifications.sub.schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createLogger } from "logger/logger.controller.js";
|
||||
import { minutesToCron } from "utils/croner.minConvert.js";
|
||||
import { createCronJob, stopCronJob } from "utils/croner.utils.js";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { notifications } from "../db/schema/notifications.schema.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
|
||||
const log = createLogger({ module: "notifications", subModule: "start" });
|
||||
|
||||
export const startNotifications = async () => {
|
||||
// get active notification
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
db.select().from(notifications).where(eq(notifications.active, true)),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when getting notifications.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (data.length === 0) {
|
||||
log.info(
|
||||
{},
|
||||
"There are know currently active notifications to start up.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// get the subs and see if we have any subs currently so we can fire up the notification
|
||||
const { data: sub, error: subError } = await tryCatch(
|
||||
db.select().from(notificationSub),
|
||||
);
|
||||
|
||||
if (subError) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when getting subscriptions.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub.length === 0) {
|
||||
log.info({}, "There are know currently active subscriptions.");
|
||||
return;
|
||||
}
|
||||
|
||||
const emailString = [
|
||||
...new Set(
|
||||
sub.flatMap((e) =>
|
||||
e.emails?.map((email) => email.trim().toLowerCase()),
|
||||
),
|
||||
),
|
||||
].join(";");
|
||||
|
||||
for (const n of data) {
|
||||
createCronJob(
|
||||
n.name,
|
||||
minutesToCron(parseInt(n.interval ?? "15", 10)),
|
||||
async () => {
|
||||
try {
|
||||
const { default: runFun } = await import(
|
||||
`./notification.${n.name.trim()}.js`
|
||||
);
|
||||
await runFun(n, emailString);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error starting the notification",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const modifiedNotification = async (id: string) => {
|
||||
// when a notifications subscribed to, updated, deleted we want to get the info and rerun the startup on the single notification.
|
||||
const { data, error } = await tryCatch(
|
||||
db.select().from(notifications).where(eq(notifications.id, id)),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when getting notifications.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (!data[0]?.active) {
|
||||
stopCronJob(data[0]?.name ?? "");
|
||||
return;
|
||||
}
|
||||
|
||||
// get the subs for the specific id as we only want to up the modified one
|
||||
const { data: sub, error: subError } = await tryCatch(
|
||||
db
|
||||
.select()
|
||||
.from(notificationSub)
|
||||
.where(eq(notificationSub.notificationId, id)),
|
||||
);
|
||||
|
||||
if (subError) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when getting subscriptions.",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub.length === 0) {
|
||||
log.info({}, "There are know currently active subscriptions.");
|
||||
stopCronJob(data[0]?.name ?? "");
|
||||
return;
|
||||
}
|
||||
|
||||
const emailString = [
|
||||
...new Set(
|
||||
sub.flatMap((e) =>
|
||||
e.emails?.map((email) => email.trim().toLowerCase()),
|
||||
),
|
||||
),
|
||||
].join(";");
|
||||
|
||||
createCronJob(
|
||||
data[0].name,
|
||||
minutesToCron(parseInt(data[0].interval ?? "15", 10)),
|
||||
async () => {
|
||||
try {
|
||||
const { default: runFun } = await import(
|
||||
`./notification.${data[0]?.name.trim()}.js`
|
||||
);
|
||||
await runFun(data[0], emailString);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error starting the notification",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
6
backend/notification/notification.reprintLabels.ts
Normal file
6
backend/notification/notification.reprintLabels.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
const reprint = (data: any, emails: string) => {
|
||||
console.log(data);
|
||||
console.log(emails);
|
||||
};
|
||||
|
||||
export default reprint;
|
||||
54
backend/notification/notification.route.ts
Normal file
54
backend/notification/notification.route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { notifications } from "db/schema/notifications.schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import { auth } from "utils/auth.utils.js";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.get("/", async (req, res: Response) => {
|
||||
const hasPermissions = await auth.api.userHasPermission({
|
||||
body: {
|
||||
//userId: req?.user?.id,
|
||||
role: req.user?.roles as any,
|
||||
permissions: {
|
||||
notifications: ["readAll"], // This must match the structure in your access control
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data: nName, error: nError } = await tryCatch(
|
||||
db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(
|
||||
!hasPermissions.success ? eq(notifications.active, true) : undefined,
|
||||
)
|
||||
.orderBy(notifications.name),
|
||||
);
|
||||
|
||||
if (nError) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "get",
|
||||
message: `There was an error getting the notifications `,
|
||||
data: [nError],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "get",
|
||||
message: `All current notifications`,
|
||||
data: nName ?? [],
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
export default r;
|
||||
20
backend/notification/notification.routes.ts
Normal file
20
backend/notification/notification.routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Express } from "express";
|
||||
import { requireAuth } from "../middleware/auth.middleware.js";
|
||||
import getNotifications from "./notification.route.js";
|
||||
import updateNote from "./notification.update.route.js";
|
||||
import deleteSub from "./notificationSub.delete.route.js";
|
||||
import subs from "./notificationSub.get.route.js";
|
||||
import newSub from "./notificationSub.post.route.js";
|
||||
import updateSub from "./notificationSub.update.route.js";
|
||||
|
||||
export const setupNotificationRoutes = (baseUrl: string, app: Express) => {
|
||||
//stats will be like this as we dont need to change this
|
||||
app.use(`${baseUrl}/api/notification`, requireAuth, getNotifications);
|
||||
app.use(`${baseUrl}/api/notification`, requireAuth, updateNote);
|
||||
app.use(`${baseUrl}/api/notification/sub`, requireAuth, subs);
|
||||
app.use(`${baseUrl}/api/notification/sub`, requireAuth, newSub);
|
||||
app.use(`${baseUrl}/api/notification/sub`, requireAuth, updateSub);
|
||||
app.use(`${baseUrl}/api/notification/sub`, requireAuth, deleteSub);
|
||||
|
||||
// all other system should be under /api/system/*
|
||||
};
|
||||
81
backend/notification/notification.update.route.ts
Normal file
81
backend/notification/notification.update.route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { notifications } from "db/schema/notifications.schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import z from "zod";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { requirePermission } from "../middleware/auth.requiredPerms.middleware.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
import { modifiedNotification } from "./notification.controller.js";
|
||||
|
||||
const r = Router();
|
||||
|
||||
const updateNote = z.object({
|
||||
description: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
interval: z.string().optional(),
|
||||
options: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
});
|
||||
|
||||
r.patch(
|
||||
"/:id",
|
||||
requirePermission({ notifications: ["update"] }),
|
||||
async (req, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const validated = updateNote.parse(req.body);
|
||||
|
||||
const { data: nName, error: nError } = await tryCatch(
|
||||
db
|
||||
.update(notifications)
|
||||
.set(validated)
|
||||
.where(eq(notifications.id, id as string))
|
||||
.returning(),
|
||||
);
|
||||
|
||||
await modifiedNotification(id as string);
|
||||
|
||||
if (nError) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "update",
|
||||
message: `There was an error getting the notifications `,
|
||||
data: [nError],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "update",
|
||||
message: `Notification was updated`,
|
||||
data: nName ?? [],
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "routes",
|
||||
subModule: "notification",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
export default r;
|
||||
83
backend/notification/notificationSub.delete.route.ts
Normal file
83
backend/notification/notificationSub.delete.route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { notificationSub } from "db/schema/notifications.sub.schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import z from "zod";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
import { modifiedNotification } from "./notification.controller.js";
|
||||
|
||||
const newSubscribe = z.object({
|
||||
emails: z
|
||||
.email()
|
||||
.array()
|
||||
|
||||
.describe("An array of emails"),
|
||||
userId: z.string().describe("User id."),
|
||||
notificationId: z
|
||||
.string()
|
||||
|
||||
.describe("Notification id"),
|
||||
});
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.delete("/", async (req, res: Response) => {
|
||||
try {
|
||||
const validated = newSubscribe.parse(req.body);
|
||||
const { data, error } = await tryCatch(
|
||||
db
|
||||
.delete(notificationSub)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSub.userId, validated.userId),
|
||||
eq(notificationSub.notificationId, validated.notificationId),
|
||||
),
|
||||
)
|
||||
.returning(),
|
||||
);
|
||||
|
||||
await modifiedNotification(validated.notificationId);
|
||||
|
||||
if (error) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `There was an error deleting the subscription `,
|
||||
data: [error],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `Subscription deleted`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "routes",
|
||||
subModule: "notification",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export default r;
|
||||
55
backend/notification/notificationSub.get.route.ts
Normal file
55
backend/notification/notificationSub.get.route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { notificationSub } from "db/schema/notifications.sub.schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import { auth } from "utils/auth.utils.js";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.get("/", async (req, res: Response) => {
|
||||
const hasPermissions = await auth.api.userHasPermission({
|
||||
body: {
|
||||
//userId: req?.user?.id,
|
||||
role: req.user?.roles as any,
|
||||
permissions: {
|
||||
notifications: ["readAll"], // This must match the structure in your access control
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
db
|
||||
.select()
|
||||
.from(notificationSub)
|
||||
.where(
|
||||
!hasPermissions.success
|
||||
? eq(notificationSub.userId, `${req?.user?.id ?? ""}`)
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `There was an error getting subscriptions `,
|
||||
data: [error],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `Subscription deleted`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
export default r;
|
||||
75
backend/notification/notificationSub.post.route.ts
Normal file
75
backend/notification/notificationSub.post.route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { notificationSub } from "db/schema/notifications.sub.schema.js";
|
||||
import { type Response, Router } from "express";
|
||||
import z from "zod";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
import { modifiedNotification } from "./notification.controller.js";
|
||||
|
||||
const newSubscribe = z.object({
|
||||
emails: z
|
||||
.email()
|
||||
.array()
|
||||
|
||||
.describe("An array of emails"),
|
||||
userId: z.string().describe("User id."),
|
||||
notificationId: z
|
||||
.string()
|
||||
|
||||
.describe("Notification id"),
|
||||
});
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.post("/", async (req, res: Response) => {
|
||||
try {
|
||||
const validated = newSubscribe.parse(req.body);
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
db.insert(notificationSub).values(validated).returning(),
|
||||
);
|
||||
|
||||
await modifiedNotification(validated.notificationId);
|
||||
|
||||
if (error) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `There was an error getting the notifications `,
|
||||
data: [error],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `Subscribed to notification`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "routes",
|
||||
subModule: "notification",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export default r;
|
||||
84
backend/notification/notificationSub.update.route.ts
Normal file
84
backend/notification/notificationSub.update.route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { notificationSub } from "db/schema/notifications.sub.schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import z from "zod";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
import { modifiedNotification } from "./notification.controller.js";
|
||||
|
||||
const newSubscribe = z.object({
|
||||
emails: z.email().array().describe("An array of emails"),
|
||||
userId: z.string().describe("User id."),
|
||||
notificationId: z.string().describe("Notification id"),
|
||||
});
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.patch("/", async (req, res: Response) => {
|
||||
try {
|
||||
const validated = newSubscribe.parse(req.body);
|
||||
|
||||
const emails = validated.emails
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const uniqueEmails = [...new Set(emails)];
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
db
|
||||
.update(notificationSub)
|
||||
.set({ emails: uniqueEmails })
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSub.userId, validated.userId),
|
||||
eq(notificationSub.notificationId, validated.notificationId),
|
||||
),
|
||||
)
|
||||
.returning(),
|
||||
);
|
||||
|
||||
await modifiedNotification(validated.notificationId);
|
||||
|
||||
if (error) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "update",
|
||||
message: `There was an error updating the notifications `,
|
||||
data: [error],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "update",
|
||||
message: `Subscription updated`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "routes",
|
||||
subModule: "notification",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export default r;
|
||||
50
backend/notification/notifications.master.ts
Normal file
50
backend/notification/notifications.master.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { db } from "db/db.controller.js";
|
||||
import {
|
||||
type NewNotification,
|
||||
notifications,
|
||||
} from "db/schema/notifications.schema.js";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { tryCatch } from "utils/trycatch.utils.js";
|
||||
import { createLogger } from "../logger/logger.controller.js";
|
||||
|
||||
const note: NewNotification[] = [
|
||||
{
|
||||
name: "reprintLabels",
|
||||
description:
|
||||
"Monitors the labels that are printed and returns a there data, if one falls withing the time frame.",
|
||||
active: false,
|
||||
interval: "10",
|
||||
options: [{ prodID: 1 }],
|
||||
},
|
||||
];
|
||||
|
||||
export const createNotifications = async () => {
|
||||
const log = createLogger({ module: "notifications", subModule: "create" });
|
||||
const { data, error } = await tryCatch(
|
||||
db
|
||||
.insert(notifications)
|
||||
.values(note)
|
||||
.onConflictDoUpdate({
|
||||
target: notifications.name,
|
||||
set: {
|
||||
description: sql`excluded.description`,
|
||||
},
|
||||
// where: sql`
|
||||
// settings.seed_version IS NULL
|
||||
// OR settings.seed_version < excluded.seed_version
|
||||
// `,
|
||||
})
|
||||
.returning(),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when adding or updating the notifications.",
|
||||
);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
log.info({}, "All notifications were added/updated");
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Express } from "express";
|
||||
import { setupNotificationRoutes } from "notification/notification.routes.js";
|
||||
import { setupAuthRoutes } from "./auth/auth.routes.js";
|
||||
// import the routes and route setups
|
||||
import { setupApiDocsRoutes } from "./configs/scaler.config.js";
|
||||
@@ -17,4 +18,5 @@ export const setupRoutes = (baseUrl: string, app: Express) => {
|
||||
setupAuthRoutes(baseUrl, app);
|
||||
setupUtilsRoutes(baseUrl, app);
|
||||
setupOpendockRoutes(baseUrl, app);
|
||||
setupNotificationRoutes(baseUrl, app);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import { startNotifications } from "notification/notification.controller.js";
|
||||
import { createNotifications } from "notification/notifications.master.js";
|
||||
import createApp from "./app.js";
|
||||
import { db } from "./db/db.controller.js";
|
||||
import { dbCleanup } from "./db/dbCleanup.controller.js";
|
||||
@@ -47,6 +49,10 @@ const start = async () => {
|
||||
dbCleanup("jobs", 30),
|
||||
);
|
||||
createCronJob("logsCleanup", "0 15 5 * * *", () => dbCleanup("logs", 120));
|
||||
|
||||
// one shots only needed to run on server startups
|
||||
createNotifications();
|
||||
startNotifications();
|
||||
}, 5 * 1000);
|
||||
|
||||
server.listen(port, async () => {
|
||||
|
||||
26
backend/utils/auth.permissions.ts
Normal file
26
backend/utils/auth.permissions.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
|
||||
export const statement = {
|
||||
app: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
user: ["ban"],
|
||||
quality: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
notifications: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
} as const;
|
||||
|
||||
export const ac = createAccessControl(statement);
|
||||
|
||||
export const user = ac.newRole({
|
||||
app: ["read", "create"],
|
||||
notifications: ["read", "create"],
|
||||
});
|
||||
|
||||
export const admin = ac.newRole({
|
||||
app: ["read", "create", "update"],
|
||||
});
|
||||
|
||||
export const systemAdmin = ac.newRole({
|
||||
app: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
user: ["ban"],
|
||||
quality: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
notifications: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import {
|
||||
admin,
|
||||
admin as adminPlugin,
|
||||
// apiKey,
|
||||
// createAuthMiddleware,
|
||||
//customSession,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
//import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import * as rawSchema from "../db/schema/auth.schema.js";
|
||||
import { ac, admin, systemAdmin, user } from "./auth.permissions.js";
|
||||
import { allowedOrigins } from "./cors.utils.js";
|
||||
import { sendEmail } from "./sendEmail.utils.js";
|
||||
|
||||
@@ -44,7 +45,14 @@ export const auth = betterAuth({
|
||||
plugins: [
|
||||
jwt({ jwt: { expirationTime: "1h" } }),
|
||||
//apiKey(),
|
||||
admin(),
|
||||
adminPlugin({
|
||||
ac,
|
||||
roles: {
|
||||
admin,
|
||||
user,
|
||||
systemAdmin,
|
||||
},
|
||||
}),
|
||||
lastLoginMethod(),
|
||||
username({
|
||||
minUsernameLength: 5,
|
||||
|
||||
38
backend/utils/croner.minConvert.ts
Normal file
38
backend/utils/croner.minConvert.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createLogger } from "../logger/logger.controller.js";
|
||||
|
||||
const minTime = 5;
|
||||
export const minutesToCron = (minutes: number): string => {
|
||||
const log = createLogger({ module: "system", subModule: "croner" });
|
||||
if (minutes < minTime) {
|
||||
log.error(
|
||||
{},
|
||||
`Conversion of less then ${minTime} min and is not allowed on this server`,
|
||||
);
|
||||
}
|
||||
|
||||
// Every X minutes (under 60)
|
||||
if (minutes < 60) {
|
||||
return `*/${minutes} * * * *`;
|
||||
}
|
||||
|
||||
// Every X hours (clean division)
|
||||
if (minutes % 60 === 0 && minutes < 1440) {
|
||||
const hours = minutes / 60;
|
||||
return `0 0 */${hours} * * *`;
|
||||
}
|
||||
|
||||
// Every day
|
||||
if (minutes === 1440) {
|
||||
return `0 0 8 * * *`; // 8am
|
||||
}
|
||||
|
||||
// Every X days (clean division)
|
||||
if (minutes % 1440 === 0) {
|
||||
const days = minutes / 1440;
|
||||
return `0 0 0 */${days} * *`;
|
||||
}
|
||||
|
||||
// Fallback (not cleanly divisible)
|
||||
// Cron can't represent this perfectly → run every X minutes as approximation
|
||||
return `0 */${minutes} * * * *`;
|
||||
};
|
||||
@@ -3,7 +3,14 @@ import { createLogger } from "../logger/logger.controller.js";
|
||||
|
||||
interface Data<T = unknown[]> {
|
||||
success: boolean;
|
||||
module: "system" | "ocp" | "routes" | "datamart" | "utils" | "opendock";
|
||||
module:
|
||||
| "system"
|
||||
| "ocp"
|
||||
| "routes"
|
||||
| "datamart"
|
||||
| "utils"
|
||||
| "opendock"
|
||||
| "notification";
|
||||
subModule:
|
||||
| "db"
|
||||
| "labeling"
|
||||
@@ -15,7 +22,13 @@ interface Data<T = unknown[]> {
|
||||
| "datamart"
|
||||
| "jobs"
|
||||
| "apt"
|
||||
| "settings";
|
||||
| "settings"
|
||||
| "get"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "post"
|
||||
| "notification"
|
||||
| "delete";
|
||||
level: "info" | "error" | "debug" | "fatal";
|
||||
message: string;
|
||||
room?: string;
|
||||
|
||||
20
brunoApi/notifications/Get All notifications.bru
Normal file
20
brunoApi/notifications/Get All notifications.bru
Normal file
@@ -0,0 +1,20 @@
|
||||
meta {
|
||||
name: Get All notifications.
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{url}}/api/notification
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
Passing all as a query param will return all queries active and none active
|
||||
}
|
||||
24
brunoApi/notifications/Subscribe to notification.bru
Normal file
24
brunoApi/notifications/Subscribe to notification.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: Subscribe to notification
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{url}}/api/notification/sub
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"userId":"m6AbQXFwOXoX3YKLfwWgq2LIdDqS5jqv",
|
||||
"notificationId": "0399eb2a-39df-48b7-9f1c-d233cec94d2e",
|
||||
"emails": ["blake.mattes@alpla.com"]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
8
brunoApi/notifications/folder.bru
Normal file
8
brunoApi/notifications/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: notifications
|
||||
seq: 7
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
24
brunoApi/notifications/remove sub notification.bru
Normal file
24
brunoApi/notifications/remove sub notification.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: remove sub notification
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
delete {
|
||||
url: {{url}}/api/notification/sub
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"userId":"0kHd6Kkdub4GW6rK1qa1yjWwqXtvykqT",
|
||||
"notificationId": "0399eb2a-39df-48b7-9f1c-d233cec94d2e",
|
||||
"emails": ["blake.mattes@alpla.com"]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
24
brunoApi/notifications/subscriptions.bru
Normal file
24
brunoApi/notifications/subscriptions.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: subscriptions
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{url}}/api/notification/sub
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"userId":"0kHd6Kkdub4GW6rK1qa1yjWwqXtvykqT",
|
||||
"notificationId": "0399eb2a-39df-48b7-9f1c-d233cec94d2e",
|
||||
"emails": ["blake.mattes@alpla.com"]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
31
brunoApi/notifications/update notification.bru
Normal file
31
brunoApi/notifications/update notification.bru
Normal file
@@ -0,0 +1,31 @@
|
||||
meta {
|
||||
name: update notification
|
||||
type: http
|
||||
seq: 6
|
||||
}
|
||||
|
||||
patch {
|
||||
url: {{url}}/api/notification/:id
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
params:path {
|
||||
id: 0399eb2a-39df-48b7-9f1c-d233cec94d2e
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"active" : false,
|
||||
"options": [{"prodId": 5}]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
Passing all as a query param will return all queries active and none active
|
||||
}
|
||||
24
brunoApi/notifications/update sub notification.bru
Normal file
24
brunoApi/notifications/update sub notification.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: update sub notification
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
patch {
|
||||
url: {{url}}/api/notification/sub
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"userId":"m6AbQXFwOXoX3YKLfwWgq2LIdDqS5jqv",
|
||||
"notificationId": "0399eb2a-39df-48b7-9f1c-d233cec94d2e",
|
||||
"emails": ["cowchmonkey@gmail.com"]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { adminClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { ac, admin, systemAdmin, user } from "./auth-permissions";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: `${window.location.origin}/lst/api/auth`,
|
||||
plugins: [adminClient()],
|
||||
plugins: [
|
||||
adminClient({
|
||||
ac,
|
||||
roles: {
|
||||
admin,
|
||||
user,
|
||||
systemAdmin,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const { useSession, signUp, signIn, signOut } = authClient;
|
||||
|
||||
21
frontend/src/lib/auth-permissions.ts
Normal file
21
frontend/src/lib/auth-permissions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
|
||||
export const statement = {
|
||||
project: ["create", "share", "update", "delete"],
|
||||
user: ["ban"],
|
||||
} as const;
|
||||
|
||||
export const ac = createAccessControl(statement);
|
||||
|
||||
export const user = ac.newRole({
|
||||
project: ["create"],
|
||||
});
|
||||
|
||||
export const admin = ac.newRole({
|
||||
project: ["create", "update"],
|
||||
});
|
||||
|
||||
export const systemAdmin = ac.newRole({
|
||||
project: ["create", "update", "delete"],
|
||||
user: ["ban"],
|
||||
});
|
||||
9
migrations/0016_colorful_exiles.sql
Normal file
9
migrations/0016_colorful_exiles.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "notifications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"active" boolean DEFAULT false,
|
||||
"options" jsonb DEFAULT '[]'::jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "notify_name" ON "notifications" USING btree ("name");
|
||||
1
migrations/0017_famous_joseph.sql
Normal file
1
migrations/0017_famous_joseph.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "notifications" ADD COLUMN "interval" text DEFAULT '5';
|
||||
10
migrations/0018_lowly_wallow.sql
Normal file
10
migrations/0018_lowly_wallow.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "notification_sub" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"notification_id" uuid NOT NULL,
|
||||
"emails" text[] DEFAULT '{}',
|
||||
CONSTRAINT "notification_sub_user_notification_unique" UNIQUE("user_id","notification_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification_sub" ADD CONSTRAINT "notification_sub_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification_sub" ADD CONSTRAINT "notification_sub_notification_id_notifications_id_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."notifications"("id") ON DELETE cascade ON UPDATE no action;
|
||||
1209
migrations/meta/0016_snapshot.json
Normal file
1209
migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1216
migrations/meta/0017_snapshot.json
Normal file
1216
migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1291
migrations/meta/0018_snapshot.json
Normal file
1291
migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,27 @@
|
||||
"when": 1772415040979,
|
||||
"tag": "0015_neat_donald_blake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1774026354811,
|
||||
"tag": "0016_colorful_exiles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1774030327338,
|
||||
"tag": "0017_famous_joseph",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1774032587305,
|
||||
"tag": "0018_lowly_wallow",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
679
package-lock.json
generated
679
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -39,54 +39,54 @@
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.2",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"@commitlint/cli": "^20.4.1",
|
||||
"@commitlint/config-conventional": "^20.4.1",
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/mssql": "^9.1.9",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/net-snmp": "^3.23.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/nodemailer": "^7.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nodemailer-express-handlebars": "^4.0.6",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"commitizen": "^4.3.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"npm-check-updates": "^19.3.2",
|
||||
"npm-check-updates": "^19.6.5",
|
||||
"openapi-types": "^12.1.3",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.52.0",
|
||||
"@scalar/express-api-reference": "^0.8.41",
|
||||
"@dotenvx/dotenvx": "^1.57.0",
|
||||
"@scalar/express-api-reference": "^0.9.4",
|
||||
"@socket.io/admin-ui": "^0.5.1",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.13.6",
|
||||
"better-auth": "^1.5.5",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.6",
|
||||
"croner": "^10.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"express": "^5.2.1",
|
||||
"husky": "^9.1.7",
|
||||
"morgan": "^1.10.1",
|
||||
"mssql": "^12.2.0",
|
||||
"multer": "^2.0.2",
|
||||
"mssql": "^12.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"net-snmp": "^3.26.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"nodemailer": "^8.0.3",
|
||||
"nodemailer-express-handlebars": "^7.0.0",
|
||||
"pg": "^8.18.0",
|
||||
"pg": "^8.20.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postgres": "^3.4.8",
|
||||
|
||||
Reference in New Issue
Block a user