Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/db/src/drizzle/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { alarms } from "@databuddy/db/schema";
import { eq, and } from "drizzle-orm";

export const sendNotification = async (db: any, websiteId: string, status: string) => {
const siteAlarms = await db
.select()
.from(alarms)
.where(
and(
eq(alarms.websiteId, websiteId),
// Условие: проверяем статус внутри JSON triggerConditions
eq(alarms.triggerConditions, { status: status })
)
);

for (const alarm of siteAlarms) {
// В консоль для логов сервера
console.log(`[Alarm] Site ${websiteId} is ${status}. Notifying: ${alarm.channels.join(", ")}`);

// Интеграция с провайдерами (Slack/Discord) будет добавлена в следующих итерациях
}
};
9 changes: 9 additions & 0 deletions packages/db/src/drizzle/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1116,3 +1116,12 @@ export type Feedback = typeof feedback.$inferSelect;
export type FeedbackInsert = typeof feedback.$inferInsert;
export type FeedbackRedemption = typeof feedbackRedemptions.$inferSelect;
export type FeedbackRedemptionInsert = typeof feedbackRedemptions.$inferInsert;
// --- ALARMS SYSTEM ---
export const alarms = pgTable("alarms", {
id: text("id").primaryKey(), // ID аларма (обычно генерируется через createId)
websiteId: text("website_id").notNullable(), // Привязка к сайту
channels: jsonb("channels").$type<string[]>().notNullable().default([]), // ['slack', 'discord', 'email']
triggerConditions: jsonb("trigger_conditions").notNullable(), // Условия срабатывания
createdAt: timestamp("created_at").defaultNow().notNullable(),
updatedAt: timestamp("updated_at").defaultNow().notNullable(),
});
Comment on lines +1119 to +1127
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 .notNullable() is not a valid Drizzle ORM method — should be .notNull()

Every existing column in this schema that is non-nullable uses .notNull(). Drizzle ORM's column builder API exposes .notNull(), not .notNullable(). Calling .notNullable() will throw a TypeError: ... is not a function at startup.

Suggested change
// --- ALARMS SYSTEM ---
export const alarms = pgTable("alarms", {
id: text("id").primaryKey(), // ID аларма (обычно генерируется через createId)
websiteId: text("website_id").notNullable(), // Привязка к сайту
channels: jsonb("channels").$type<string[]>().notNullable().default([]), // ['slack', 'discord', 'email']
triggerConditions: jsonb("trigger_conditions").notNullable(), // Условия срабатывания
createdAt: timestamp("created_at").defaultNow().notNullable(),
updatedAt: timestamp("updated_at").defaultNow().notNullable(),
});
export const alarms = pgTable("alarms", {
id: text("id").primaryKey().notNull(),
websiteId: text("website_id").notNull(),
channels: jsonb("channels").$type<string[]>().notNull().default([]),
triggerConditions: jsonb("trigger_conditions").$type<{ status: string; durationMinutes: number }>().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Comment on lines +1119 to +1127
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing website_id index, foreign key constraint, and type exports

Every other table with a website_id foreign key in this schema has:

  1. A btree index on website_id for query performance (e.g. index("alarms_website_id_idx").using("btree", table.websiteId.asc().nullsLast().op("text_ops"))).
  2. A foreignKey constraint referencing websites.id with .onDelete("cascade") so alarms are cleaned up when a website is deleted.
  3. Corresponding type exports at the bottom of the file (export type Alarm = typeof alarms.$inferSelect; export type AlarmInsert = typeof alarms.$inferInsert;).

Without the foreign key + cascade, orphaned alarm rows will accumulate. Without the index, listing alarms per website will degrade to a full table scan.

87 changes: 87 additions & 0 deletions packages/rpc/src/routers/alarms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import { protectedProcedure, router } from "../trpc";
import { alarms } from "@databuddy/db/schema";
Comment on lines +3 to +4
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Import from non-existent tRPC module

The entire router imports protectedProcedure and router from "../trpc", but no trpc.ts file exists in this package. The project has migrated to oRPC (@orpc/server). The correct module is "../orpc", and it does not export a router helper — routers are plain objects in this codebase. This will cause a build-time module-not-found error.

The pattern used by every other router (e.g. annotations.ts) is:

import { protectedProcedure, publicProcedure } from "../orpc";

And procedures are defined via .route({...}).input(...).output(...).handler(async ({ context, input }) => { ... }) instead of .query()/.mutation() with { ctx, input }.

The entire router needs to be rewritten to use the oRPC API.

import { createId } from "@paralleldrive/cuid2";
Comment on lines +1 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 alarmsRouter is not registered in root.ts

packages/rpc/src/root.ts defines appRouter by composing all sub-routers, but alarmsRouter is never imported or added to that object. Even after the other issues are fixed, these endpoints will be completely unreachable through the API.

alarmsRouter needs to be imported and added:

// root.ts
import { alarmsRouter } from "./routers/alarms";
// ...
export const appRouter = {
  alarms: alarmsRouter,
  // existing entries...
};


export const createAlarmSchema = z.object({
websiteId: z.string().min(1),
channels: z.array(z.enum(["slack", "discord", "email"])).min(1),
triggerConditions: z.object({
status: z.enum(["down", "up", "degraded"]),
durationMinutes: z.number().int().min(1).default(5),
}),
});

export const updateAlarmSchema = z.object({
id: z.string().min(1),
data: createAlarmSchema.partial(),
});

export const alarmsRouter = router({
list: protectedProcedure
.input(z.object({ websiteId: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db
.select()
.from(alarms)
.where(eq(alarms.websiteId, input.websiteId));
}),

get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const result = await ctx.db
.select()
.from(alarms)
.where(eq(alarms.id, input.id))
.limit(1);

if (!result.length) throw new Error("Alarm not found");
return result[0];
}),

create: protectedProcedure
.input(createAlarmSchema)
.mutation(async ({ ctx, input }) => {
const newAlarm = await ctx.db
.insert(alarms)
.values({
id: createId(),
websiteId: input.websiteId,
channels: input.channels,
triggerConditions: input.triggerConditions,
})
.returning();

return newAlarm[0];
}),

update: protectedProcedure
.input(updateAlarmSchema)
.mutation(async ({ ctx, input }) => {
const updatedAlarm = await ctx.db
.update(alarms)
.set({
...input.data,
updatedAt: new Date(),
})
.where(eq(alarms.id, input.id))
.returning();

if (!updatedAlarm.length) throw new Error("Alarm not found");
return updatedAlarm[0];
}),

delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const deletedAlarm = await ctx.db
.delete(alarms)
.where(eq(alarms.id, input.id))
.returning();

if (!deletedAlarm.length) throw new Error("Alarm not found");
return deletedAlarm[0];
Comment on lines +22 to +85
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Missing workspace authorization — any authenticated user can access any website's alarms

None of the procedures call withWorkspace() to verify that the authenticated user actually has access to the target website. This means any user with a valid session can list, create, update, or delete alarms for any website in the system, including websites they don't own.

Every other router that operates on website-scoped data (annotations, goals, funnels, etc.) gates access through withWorkspace, for example:

await withWorkspace(context, {
    websiteId: input.websiteId,
    permissions: ["read"], // or ["update"] for mutations
});

Additionally, get, update, and delete accept an alarm id without first loading the associated websiteId and checking access against it — an attacker can enumerate and manipulate alarms across all websites.

This needs to be addressed before the router goes live.

}),
});
11 changes: 11 additions & 0 deletions packages/rpc/src/routers/uptime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from "zod";
import { rpcError } from "../errors";
import { protectedProcedure } from "../orpc";
import { withWorkspace } from "../procedures/with-workspace";
import { sendNotification } from "../../../db/src/drizzle/notifications";

const client = new Client({ token: process.env.UPSTASH_QSTASH_TOKEN });

Expand Down Expand Up @@ -406,6 +407,10 @@ export const uptimeRouter = {
.set({ isPaused: input.pause, updatedAt: new Date() })
.where(eq(uptimeSchedules.id, input.scheduleId)),
]);

// ВЫЗОВ УВЕДОМЛЕНИЯ ПРИ СМЕНЕ СТАТУСА (ПАУЗА/РЕЗЮМЕ)
await sendNotification(db, schedule.websiteId ?? "", input.pause ? "paused" : "active");

} catch (error) {
logger.error(
{ scheduleId: input.scheduleId, error },
Expand Down Expand Up @@ -447,6 +452,9 @@ export const uptimeRouter = {
.set({ isPaused: true, updatedAt: new Date() })
.where(eq(uptimeSchedules.id, input.scheduleId)),
]);

await sendNotification(db, schedule.websiteId ?? "", "paused");

} catch (error) {
logger.error(
{ scheduleId: input.scheduleId, error },
Expand Down Expand Up @@ -484,6 +492,9 @@ export const uptimeRouter = {
.set({ isPaused: false, updatedAt: new Date() })
.where(eq(uptimeSchedules.id, input.scheduleId)),
]);

await sendNotification(db, schedule.websiteId ?? "", "active");

} catch (error) {
logger.error(
{ scheduleId: input.scheduleId, error },
Expand Down