feat/onboarding (#337)

* feat(onboarding): add cookie helper, types and mock data

* feat(onboarding): add stepper, contextual preview and shell

* feat(onboarding): add sso-gate step

* feat(onboarding): add account-info step with real signup

* feat(onboarding): add security step

* feat(onboarding): add preferences step

* fix(onboarding): validate avatar upload size/type and handle FileReader errors

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

* feat(onboarding): add notifier step reusing real provider list

* feat(onboarding): add storage step reusing real provider list

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

* feat(onboarding): add agent-waiting step with simulated ping

* feat(onboarding): add project-create step

* feat(onboarding): add db-settings step with per-db repeat

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

* feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie

* fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written

* docs: add onboarding channel config + style refactor spec

* docs: add conditional step skip when no agents to spec

* docs(onboarding): add implementation plan for channel config flow and style refactor

* feat(onboarding): extend OnboardingChannel with name and config fields

* feat(onboarding): add two-phase config flow and org-combobox style to notifier step

* fix(onboarding): prevent duplicate notifier providers and guard Continue button

* feat(onboarding): add two-phase config flow and org-combobox style to storage step

* style(onboarding): restyle sso-gate provider buttons to org-combobox card style

* style(onboarding): restyle security method choice to org-combobox card style

* style(onboarding): restyle theme toggles in preferences step to org-combobox card style

* style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style

* feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created

* feat(onboarding): update type system — meta, firstName/lastName, org.id, databases

* feat(onboarding): add settings.onboarding column + markOnboardingDoneAction

* feat(onboarding): add resolveOnboardingState server function

* fix(onboarding): remove unused imports from onboarding-state.ts

* style(onboarding): add branding to shell, fix progress bar full width

* feat(onboarding): replace preview panel with stepper checklist

* feat(onboarding): add step-login replacing sso-gate

* fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors

* feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate

* feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step

* feat(onboarding): step-security reads passkeyEnabled from flowData.meta

* feat(onboarding): step-org-create calls real createOrganizationAction

* fix(onboarding): step-org-create — simplify error handling, throw on empty name

* feat(onboarding): notifier + storage steps call real actions + findOrCreate on back

* feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate

* feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery

* feat(onboarding): add step-agent-key with edgeKey generation via server action

* feat(onboarding): step-project-create uses real action + real DBs from flowData

* feat(onboarding): wire steps — add login + agent-key, remove sso-gate

* feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume

* chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation

* feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org

* feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact

* feat(onboarding): agent-waiting step is now skippable

* feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state

* fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false

* fix(onboarding): verify session after passkey registration, fallback to signIn.passkey()

* feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image

* feat(onboarding): remove org logo upload — orgs have no logo

* feat(onboarding): show Next button in shell when navigating backwards

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

* fix(onboarding): trim project name, standardize error narrowing in org/project hooks

* fix(onboarding): use narrowed createData.value in use-create-org

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

* refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files

* docs: add onboarding theme persistence & progress bar fix spec

* docs: add onboarding theme & progress bar implementation plan

* fix(onboarding): persist theme to db on selection in StepPreferences

* fix(onboarding): recalculate progress bar from full STEP_ORDER position

* fix(theme): sync user theme from DB on all routes, not only dashboard

* fix(theme): use current theme as fallback in preferences, prevent session override of localStorage

* fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected

* fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash

* fix(onboarding): resume at db-settings instead of finish if no database exists for the project

* fix(onboarding): redirect to agent-create if project exists but no agent is found

* fix(onboarding): allow going from agent-create to project-create if a project already exists

* fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create

* fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist

* feat(onboarding): allow user to choose between passkey and password during registration if both are enabled

* feat(onboarding): replace tabs with single form and multiple buttons for auth choice

* style(onboarding): improve empty auth state UI with lucide icon and better layout

* fix(onboarding): completely hide back button on the first step instead of disabling it

* fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step

* fix(onboarding): correctly show select placeholder when default value does not exist in the list

* refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly

* feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading

* fix(onboarding): skip db-settings step if no database is found on the agent

* fix(onboarding): do not skip db settings if agent is connected but no databases found

* refactor(onboarding): skip db settings entirely if no database is selected in project create

* feat(onboarding): auto-save database selections on project create instantly

* style(onboarding): replace button opacity flash with specific spinner animation on db select

* feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

* feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence

* fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys

* docs(sdd): append hotfix summary for applyMutation and list keys

* fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

* fix(settings): make avatar selector, dicebear picker and tab bar responsive

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

* refactor(agents): reorganize into actions/components/schemas/hooks

* refactor(auth): reorganize into actions/components/schemas

* refactor(channel): reorganize into components/schemas, nest notifications+storages under components

* refactor(database): reorganize into actions/components/schemas

* refactor(layout): move all components to components/

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

* refactor(notifications): reorganize into components/types/utils

* refactor(organizations): reorganize into actions/components/schemas/hooks/utils

* refactor(profile): reorganize into actions/components/schemas

* refactor(projects): reorganize into actions/components/schemas

* refactor(settings): reorganize into actions/components/schemas/hooks

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

* refactor(theme): move all components to components/

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

* refactor(users): reorganize into actions/components/schemas

* fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths)

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

* fix: persist onboarding=true on boot when SKIP_ONBOARDING is set

* feat: remove avatarMode/dicebearStyle from DB schema and types

* feat: simplify resolveAvatarUrl — custom image first, gravatar fallback

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

* feat: add avatar reset button and explanatory tooltip on profile page

* fix: move avatar tooltip to top-right badge overlay on avatar

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Théo LAGACHE
2026-06-27 17:08:38 +02:00
committed by GitHub
co-authored by Théo LAGACHE charles-gauthereau
parent 40d3228db6
commit 40247efddd
451 changed files with 15904 additions and 5054 deletions
-18
View File
@@ -1,18 +0,0 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type BackButtonProps = React.ComponentProps<typeof Button> & {
children: React.ReactNode;
};
export default function BackButton({ children, ...props }: BackButtonProps) {
const router = useRouter();
return (
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
{children}
</Button>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { cn } from "@/lib/utils";
export type ThemeKey = "dark" | "light" | "system";
const themes: { value: ThemeKey }[] = [
{ value: "light" },
{ value: "dark" },
{ value: "system" },
];
const THEME_TEXT: Record<ThemeKey, string> = {
dark: "Dark",
light: "Light",
system: "System",
};
interface ThemeSelectorProps {
value?: string;
onSelect: (value: ThemeKey) => void;
className?: string;
}
export function ThemeSelector({
value,
onSelect,
className,
}: ThemeSelectorProps) {
return (
<div
className={cn("grid grid-cols-1 sm:grid-cols-3 gap-4 w-full", className)}
>
{themes.map((item) => {
const isDark = item.value === "dark";
const isSystem = item.value === "system";
const isActive = value === item.value;
return (
<div
key={item.value}
className={cn(
"border-2 rounded-xl p-1 cursor-pointer transition-all hover:bg-accent/50 space-y-2",
isActive ? "border-primary bg-primary/5" : "border-muted/40",
)}
onClick={() => onSelect(item.value)}
>
<div
className={cn(
"p-2 rounded-lg aspect-4/3 flex flex-col gap-2 relative overflow-hidden border",
isDark
? "bg-slate-950 border-slate-800"
: "bg-white border-slate-200",
isSystem && "bg-linear-to-br from-white to-slate-950",
)}
>
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex gap-2 flex-1 relative">
<div
className={cn(
"w-1/4 h-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex-1 flex flex-col gap-2">
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div
className={cn(
"flex-1 rounded-sm shadow-sm p-1 space-y-2 opacity-50",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
>
<div
className={cn(
"h-2 w-3/4 rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
<div
className={cn(
"h-2 w-full rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-1 px-2">
<span className="font-medium text-sm">
{THEME_TEXT[item.value]}
</span>
<div
className={cn(
"w-4 h-4 rounded-full border flex items-center justify-center transition-all",
isActive
? "border-primary bg-primary"
: "border-muted-foreground/30",
)}
>
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
)}
</div>
</div>
</div>
);
})}
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
// import * as React from "react";
// import EmailLayout from "./email-layout";
// import {Text, Section, Button} from "@react-email/components";
// import type {EventPayload} from "@/features/notifications/notifications.types";
// import type {EventPayload} from "@/features/notifications/types";
//
// export interface EmailNotificationProps {
// payload: EventPayload
@@ -48,7 +48,7 @@
import * as React from "react";
import EmailLayout from "./email-layout";
import {Text, Section, Button} from "@react-email/components";
import type {EventPayload} from "@/features/notifications/notifications.types";
import type {EventPayload} from "@/features/notifications/types";
export interface EmailNotificationProps {
payload: EventPayload;
@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "onboarding" boolean DEFAULT false NOT NULL;
+11
View File
@@ -0,0 +1,11 @@
-- Custom SQL migration file, put your code below! --
UPDATE settings SET onboarding = true
WHERE onboarding = false
AND (
EXISTS (SELECT 1 FROM organization WHERE slug <> 'default')
OR EXISTS (SELECT 1 FROM agents)
OR EXISTS (SELECT 1 FROM projects)
OR EXISTS (SELECT 1 FROM databases)
OR EXISTS (SELECT 1 FROM notification_channel)
OR EXISTS (SELECT 1 FROM storage_channel WHERE provider <> 'local')
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -456,6 +456,20 @@
"when": 1781960184847,
"tag": "0064_orange_richard_fisk",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1782474059918,
"tag": "0065_mighty_killer_shrike",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1782474105293,
"tag": "0066_milky_puma",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -20,10 +20,11 @@ export const setting = pgTable("settings", {
defaultStorageChannelId: uuid('default_storage_channel_id')
.references(() => storageChannel.id, {onDelete: "set null"}),
encryption: boolean("encryption").default(false),
onboarding: boolean("onboarding").default(false).notNull(),
...timestamps
});
export const settingRelations = relations(setting, ({one, many}) => ({
export const settingRelations = relations(setting, ({one}) => ({
storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}),
notificationChannel: one(notificationChannel, {fields: [setting.defaultNotificationChannelId], references: [notificationChannel.id]}),
}));
+33 -35
View File
@@ -1,43 +1,41 @@
import {and, desc, eq, sql} from "drizzle-orm";
import {db} from "@/db";
import {Agent, agent, organizationAgent} from "@/db/schema/08_agent";
import {Database, database} from "@/db/schema/07_database";
"use server";
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { Agent, agent, organizationAgent } from "@/db/schema/08_agent";
import { Database, database } from "@/db/schema/07_database";
export async function getOrganizationAgents(organizationId: string) {
return await db
.select({
id: agent.id,
name: agent.name,
organizationId: agent.organizationId,
slug: agent.slug,
healthErrorCount: agent.healthErrorCount,
description: agent.description,
isArchived: agent.isArchived,
lastContact: agent.lastContact,
version: agent.version,
updatedAt: agent.updatedAt,
createdAt: agent.createdAt,
deletedAt: agent.deletedAt,
databases: sql<Database[]>`
return (await db
.select({
id: agent.id,
name: agent.name,
organizationId: agent.organizationId,
slug: agent.slug,
healthErrorCount: agent.healthErrorCount,
description: agent.description,
isArchived: agent.isArchived,
lastContact: agent.lastContact,
version: agent.version,
updatedAt: agent.updatedAt,
createdAt: agent.createdAt,
deletedAt: agent.deletedAt,
databases: sql<Database[]>`
COALESCE(
json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL),
'[]'
)
`,
})
.from(organizationAgent)
.innerJoin(
agent,
eq(organizationAgent.agentId, agent.id)
)
.leftJoin(database, eq(database.agentId, agent.id))
.groupBy(agent.id)
.orderBy(desc(agent.createdAt))
.where(
and(
eq(organizationAgent.organizationId, organizationId),
eq(agent.isArchived, false)
)
) as unknown as Agent[];
})
.from(organizationAgent)
.innerJoin(agent, eq(organizationAgent.agentId, agent.id))
.leftJoin(database, eq(database.agentId, agent.id))
.groupBy(agent.id)
.orderBy(desc(agent.createdAt))
.where(
and(
eq(organizationAgent.organizationId, organizationId),
eq(agent.isArchived, false),
),
)) as unknown as Agent[];
}
+13 -13
View File
@@ -1,19 +1,19 @@
"use server"
import {eq} from "drizzle-orm";
"use server";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import { db } from "@/db";
export async function getDatabaseBackups(databaseId: string) {
return await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
return await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
with: {
restorations: true,
storages: {
with: {
restorations: true,
storages: {
with: {
storageChannel: true,
},
},
storageChannel: true,
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
},
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
}
+118 -34
View File
@@ -1,39 +1,123 @@
"use server"
import {db} from "@/db";
import {DatabaseWith} from "@/db/schema/07_database";
import {AgentWith} from "@/db/schema/08_agent";
"use server";
import { inArray } from "drizzle-orm";
import { db } from "@/db";
import { database, retentionPolicy } from "@/db/schema/07_database";
import { alertPolicy } from "@/db/schema/10_alert-policy";
import { storagePolicy } from "@/db/schema/13_storage-policy";
import { DatabaseWith } from "@/db/schema/07_database";
import { AgentWith } from "@/db/schema/08_agent";
import type {
OnboardingDbSettings,
EventKind,
} from "@/features/onboarding/types";
export async function getOrganizationAvailableDatabases(
organizationId: string,
projectId?: string
organizationId: string,
projectId?: string,
) {
const availableDatabases = (await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
with: {
agent: {
with: {
organizations: true,
},
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, { desc }) => [desc(db.createdAt)],
})) as DatabaseWith[];
const availableDatabases = (
await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
with: {
agent: {
with: {
organizations: true
}
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
return availableDatabases.filter(db => {
const agent = db.agent as AgentWith;
if (agent?.isArchived) return false;
return (
agent?.organizationId === organizationId ||
agent?.organizations?.some(org => org.organizationId === organizationId)
);
})
return availableDatabases.filter((db) => {
const agent = db.agent as AgentWith;
if (agent?.isArchived) return false;
return (
agent?.organizationId === organizationId ||
agent?.organizations?.some((org) => org.organizationId === organizationId)
);
});
}
export async function getDatabasesSettings(
databaseIds: string[],
): Promise<Record<string, OnboardingDbSettings>> {
if (databaseIds.length === 0) return {};
const [retentionPolicies, dbs, alertPolicies, storagePolicies] =
await Promise.all([
db
.select()
.from(retentionPolicy)
.where(inArray(retentionPolicy.databaseId, databaseIds)),
db
.select({ id: database.id, backupPolicy: database.backupPolicy })
.from(database)
.where(inArray(database.id, databaseIds)),
db
.select()
.from(alertPolicy)
.where(inArray(alertPolicy.databaseId, databaseIds)),
db
.select()
.from(storagePolicy)
.where(inArray(storagePolicy.databaseId, databaseIds)),
]);
const result: Record<string, OnboardingDbSettings> = {};
for (const dbId of databaseIds) {
const rp = retentionPolicies.find((r) => r.databaseId === dbId);
const dbRow = dbs.find((d) => d.id === dbId);
const alerts = alertPolicies.filter((a) => a.databaseId === dbId);
const storages = storagePolicies.filter((s) => s.databaseId === dbId);
const settings: OnboardingDbSettings = {};
if (rp) {
settings.retention = {
type: rp.type,
count: rp.count ?? 7,
days: rp.days ?? 30,
gfs: {
daily: rp.gfsDaily ?? 7,
weekly: rp.gfsWeekly ?? 4,
monthly: rp.gfsMonthly ?? 12,
yearly: rp.gfsYearly ?? 3,
},
};
}
if (dbRow) {
if (dbRow.backupPolicy) {
settings.backupMethod = "automatic";
settings.backupCron = dbRow.backupPolicy;
} else {
settings.backupMethod = "manual";
}
}
if (alerts.length > 0) {
settings.notificationPolicies = alerts.map((a) => ({
channelId: a.notificationChannelId,
eventKinds: a.eventKinds as EventKind[],
enabled: a.enabled,
}));
}
if (storages.length > 0) {
settings.storagePolicies = storages.map((s) => ({
channelId: s.storageChannelId,
enabled: s.enabled,
}));
}
result[dbId] = settings;
}
return result;
}
+172 -157
View File
@@ -1,185 +1,200 @@
import {db} from "@/db";
"use server";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, gte, isNotNull, lt} from "drizzle-orm";
import {dispatchNotification} from "@/features/notifications/notifications.dispatch";
import {EventPayload} from "@/features/notifications/notifications.types";
import {logger} from "@/lib/logger";
import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
import { EventPayload } from "@/features/notifications/types";
import { logger } from "@/lib/logger";
const log = logger.child({module: "tasks/healthcheck"});
const log = logger.child({ module: "tasks/healthcheck" });
export async function getHealthLast12hLogs({id}: { id: string }) {
const now = new Date()
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000)
export async function getHealthLast12hLogs({ id }: { id: string }) {
const now = new Date();
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000);
return db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since)
)
)
return db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since),
),
);
}
export async function deleteHealthLogsOlderThan12h() {
const now = new Date()
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000)
const now = new Date();
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000);
const logsToDelete = await db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
const logsToDelete = await db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
log.info({name: "deleteHealthLogsOlderThan12h"},`Number of logs found to delete: ${logsToDelete.length}`)
log.info(
{ name: "deleteHealthLogsOlderThan12h" },
`Number of logs found to delete: ${logsToDelete.length}`,
);
await db
.delete(drizzleDb.schemas.healthcheckLog)
.where(
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
await db
.delete(drizzleDb.schemas.healthcheckLog)
.where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
return logsToDelete.length
return logsToDelete.length;
}
export async function checkAgentsHealthError() {
const agents = await db.query.agent.findMany({
where: isNotNull(drizzleDb.schemas.agent.lastContact),
});
const agents = await db.query.agent.findMany({
where: isNotNull(drizzleDb.schemas.agent.lastContact),
});
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
const settings = await db.query.setting.findFirst({
where: (fields, { eq }) => eq(fields.name, "system"),
});
if (!settings) {
throw new Error("System settings not found");
}
if (!settings.defaultNotificationChannelId) {
log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`)
return
}
const now = new Date();
for (const agent of agents) {
if (!agent.lastContact) continue;
const lastContactDate = new Date(agent.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((agent.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.agent)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.agent.id, agent.id));
const payload: EventPayload = {
title: "Agent down",
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_agent",
data: {
agent: agent.name,
id: agent.id,
error: "Agent is down",
},
};
log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`)
await dispatchNotification(
payload,
undefined,
settings.defaultNotificationChannelId,
undefined
);
}
}
if (!settings) {
throw new Error("System settings not found");
}
if (!settings.defaultNotificationChannelId) {
log.error(
{ name: "checkAgentsHealthError" },
`No default notification channel id found.`,
);
return;
}
const now = new Date();
for (const agent of agents) {
if (!agent.lastContact) continue;
const lastContactDate = new Date(agent.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((agent.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1;
await db
.update(drizzleDb.schemas.agent)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.agent.id, agent.id));
const payload: EventPayload = {
title: "Agent down",
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_agent",
data: {
agent: agent.name,
id: agent.id,
error: "Agent is down",
},
};
log.info(
{ name: "checkAgentsHealthError", payload: payload },
`Agent Healthcheck Notification`,
);
await dispatchNotification(
payload,
undefined,
settings.defaultNotificationChannelId,
undefined,
);
}
}
}
}
export async function checkDatabasesHealthError() {
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
with: {
agent: true,
alertPolicies: true,
},
});
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
with: {
agent: true,
alertPolicies: true
const now = new Date();
for (const database of databases) {
if (!database.lastContact) continue;
const lastContactDate = new Date(database.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1;
await db
.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [
{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"],
},
]
: [];
const policiesToUse =
database.alertPolicies && database.alertPolicies.length > 0
? database.alertPolicies.filter(
(policy) =>
policy.enabled &&
policy.eventKinds.includes("error_health_database"),
)
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue;
}
})
const now = new Date();
const promises = policiesToUse.map((alertPolicy) => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
for (const database of databases) {
if (!database.lastContact) continue;
log.info(
{ name: "checkDatabasesHealthError", payload: payload },
`Database Healthcheck Notification`,
);
const lastContactDate = new Date(database.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
return dispatchNotification(
payload,
alertPolicy.id == null ? undefined : alertPolicy.id,
alertPolicy.id ? undefined : alertPolicy.notificationChannelId,
undefined,
);
});
if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"]
}]
: [];
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0)
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database"))
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue
}
const promises = policiesToUse.map(alertPolicy => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`)
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined);
});
await Promise.all(promises);
}
}
await Promise.all(promises);
}
}
}
}
+60 -28
View File
@@ -1,30 +1,62 @@
import {desc, eq} from "drizzle-orm";
import {db} from "@/db";
import {
NotificationChannel,
notificationChannel,
organizationNotificationChannel
} from "@/db/schema/09_notification-channel";
import {storageChannel} from "@/db/schema/12_storage-channel";
"use server";
export async function getOrganizationChannels(organizationId: string) {
return await db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId
})
.from(organizationNotificationChannel)
.innerJoin(
notificationChannel,
eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id)
)
.orderBy(desc(notificationChannel.createdAt))
.where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[];
import { desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import {
NotificationChannel,
notificationChannel,
organizationNotificationChannel,
} from "@/db/schema/09_notification-channel";
export async function getOrganizationChannels(
organizationId: string,
): Promise<NotificationChannel[]> {
const [orgChannels, systemChannels] = await Promise.all([
db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId,
})
.from(organizationNotificationChannel)
.innerJoin(
notificationChannel,
eq(
organizationNotificationChannel.notificationChannelId,
notificationChannel.id,
),
)
.orderBy(desc(notificationChannel.createdAt))
.where(
eq(organizationNotificationChannel.organizationId, organizationId),
),
db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId,
})
.from(notificationChannel)
.orderBy(desc(notificationChannel.createdAt))
.where(isNull(notificationChannel.organizationId)),
]);
const seen = new Set<string>();
return [...orgChannels, ...systemChannels].filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
}) as NotificationChannel[];
}
+83 -73
View File
@@ -1,81 +1,91 @@
import {and, desc, eq, gte, lte} from 'drizzle-orm';
import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log";
import {notificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db";
import {Json} from "drizzle-zod";
"use server";
import { and, desc, eq, gte, lte } from "drizzle-orm";
import {
NotificationLevel,
notificationLog,
} from "@/db/schema/11_notification-log";
import { notificationChannel } from "@/db/schema/09_notification-channel";
import { db } from "@/db";
import { Json } from "drizzle-zod";
export type NotificationLogWithRelations = {
id: string;
id: string;
title: string;
level: NotificationLevel;
success: boolean;
error: string | null;
sentAt: Date;
payload: Json | null;
content: {
title: string;
level: NotificationLevel;
success: boolean;
error: string | null;
sentAt: Date;
payload: Json | null;
content: {
title: string;
message: string;
},
channel: {
name: string;
provider: string;
} | null;
policy: {
event: string | null;
} | null;
message: string;
};
channel: {
name: string;
provider: string;
} | null;
policy: {
event: string | null;
} | null;
};
export async function getNotificationHistory(
filters?: {
channelId?: string;
policyId?: string;
organizationId?: string;
level?: NotificationLevel;
success?: boolean;
from?: Date;
to?: Date;
limit?: number;
}
): Promise<NotificationLogWithRelations[]> {
const where = [];
if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId));
if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId));
if (filters?.level) where.push(eq(notificationLog.level, filters.level));
if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
export async function getNotificationHistory(filters?: {
channelId?: string;
policyId?: string;
organizationId?: string;
level?: NotificationLevel;
success?: boolean;
from?: Date;
to?: Date;
limit?: number;
}): Promise<NotificationLogWithRelations[]> {
const where = [];
if (filters?.channelId)
where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.policyId)
where.push(eq(notificationLog.policyId, filters.policyId));
if (filters?.organizationId)
where.push(eq(notificationLog.organizationId, filters.organizationId));
if (filters?.level) where.push(eq(notificationLog.level, filters.level));
if (typeof filters?.success === "boolean")
where.push(eq(notificationLog.success, filters.success));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
const rows = await db
.select({
id: notificationLog.id,
title: notificationLog.title,
level: notificationLog.level,
success: notificationLog.success,
error: notificationLog.error,
sentAt: notificationLog.sentAt,
payload: notificationLog.payload,
content: {
title: notificationLog.title,
message: notificationLog.message,
},
channel: {
name: notificationLog.providerName,
provider: notificationLog.provider,
},
policy: {
event: notificationLog.event,
},
})
.from(notificationLog)
.leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id))
// .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
.where(and(...where))
.orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100);
const rows = await db
.select({
id: notificationLog.id,
title: notificationLog.title,
level: notificationLog.level,
success: notificationLog.success,
error: notificationLog.error,
sentAt: notificationLog.sentAt,
payload: notificationLog.payload,
content: {
title: notificationLog.title,
message: notificationLog.message,
},
channel: {
name: notificationLog.providerName,
provider: notificationLog.provider,
},
policy: {
event: notificationLog.event,
},
})
.from(notificationLog)
.leftJoin(
notificationChannel,
eq(notificationLog.channelId, notificationChannel.id),
)
// .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
.where(and(...where))
.orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100);
return rows.map(row => ({
...row,
payload: row.payload as Json,
}));
return rows.map((row) => ({
...row,
payload: row.payload as Json,
}));
}
+29
View File
@@ -0,0 +1,29 @@
"use server";
import { db } from "@/db";
import { and, eq, inArray, ne } from "drizzle-orm";
import { member } from "@/db/schema/04_member";
import { organization } from "@/db/schema/03_organization";
export async function getUserOrganization(userId: string) {
const memberRow = await db.query.member.findFirst({
columns: { organizationId: true },
where: eq(member.userId, userId),
});
if (!memberRow) return null;
return db.query.organization.findFirst({
where: eq(organization.id, memberRow.organizationId),
});
}
export async function getUserOwnOrganization(userId: string) {
const memberRows = await db.query.member.findMany({
columns: { organizationId: true },
where: eq(member.userId, userId),
});
if (!memberRows.length) return null;
const orgIds = memberRows.map((r) => r.organizationId);
return db.query.organization.findFirst({
where: and(inArray(organization.id, orgIds), ne(organization.slug, "default")),
});
}
+70 -54
View File
@@ -1,61 +1,77 @@
import {getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
"use server";
import { getOrganization } from "@/lib/auth/auth";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { project } from "@/db/schema/06_project";
export async function getOrganizationProject(organizationId: string) {
return db.query.project.findFirst({
where: eq(project.organizationId, organizationId),
with: {
databases: true,
},
});
}
export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: {
organizationSlug: string, projectId: string
export const getOrganizationProjectDatabases = async ({
organizationSlug,
projectId,
}: {
organizationSlug: string;
projectId: string;
}) => {
try {
try {
const organization = await getOrganization({});
const organization = await getOrganization({});
if (!organization) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
const databasesProject = await db.query.project.findFirst({
where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)),
with: {
databases: true
}
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id)
}
} catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
if (!organization) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
const databasesProject = await db.query.project.findFirst({
where: and(
eq(drizzleDb.schemas.project.organizationId, organization.id),
eq(drizzleDb.schemas.project.id, projectId),
),
with: {
databases: true,
},
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id),
};
} catch (e: any) {
const errorMessage =
e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
}
};
+12
View File
@@ -0,0 +1,12 @@
"use server";
import { db } from "@/db";
export async function getSettings() {
return db.query.setting.findFirst();
}
export async function isOnboardingDone(): Promise<boolean> {
const settings = await db.query.setting.findFirst();
return settings?.onboarding ?? false;
}
+55 -23
View File
@@ -1,25 +1,57 @@
import {desc, eq} from "drizzle-orm";
import {db} from "@/db";
import {organizationStorageChannel, StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
"use server";
export async function getOrganizationStorageChannels(organizationId: string) {
return await db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(organizationStorageChannel)
.innerJoin(
storageChannel,
eq(organizationStorageChannel.storageChannelId, storageChannel.id)
)
.orderBy(desc(storageChannel.createdAt))
.where(eq(organizationStorageChannel.organizationId, organizationId)) as unknown as StorageChannel[];
import { desc, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import {
organizationStorageChannel,
StorageChannel,
storageChannel,
} from "@/db/schema/12_storage-channel";
export async function getOrganizationStorageChannels(
organizationId: string,
): Promise<StorageChannel[]> {
const [orgChannels, systemChannels] = await Promise.all([
db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(organizationStorageChannel)
.innerJoin(
storageChannel,
eq(organizationStorageChannel.storageChannelId, storageChannel.id),
)
.orderBy(desc(storageChannel.createdAt))
.where(eq(organizationStorageChannel.organizationId, organizationId)),
db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(storageChannel)
.orderBy(desc(storageChannel.createdAt))
.where(isNull(storageChannel.organizationId)),
]);
const seen = new Set<string>();
return [...orgChannels, ...systemChannels].filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
}) as StorageChannel[];
}
+41 -32
View File
@@ -1,40 +1,49 @@
import {SignUpUser} from "@/types/auth";
import {hashPassword} from "better-auth/crypto";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {User, UserThemeEnum} from "@/db/schema/02_user";
import {assertValidPassword} from "@/utils/password";
"use server";
import { SignUpUser } from "@/types/auth";
import { hashPassword } from "better-auth/crypto";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { User, UserThemeEnum } from "@/db/schema/02_user";
import { assertValidPassword } from "@/utils/password";
export async function hasUsers(): Promise<boolean> {
const result = await db.select().from(drizzleDb.schemas.user).limit(1);
return result.length > 0;
}
export async function createUserDb(data: SignUpUser): Promise<User> {
assertValidPassword(data.password);
assertValidPassword(data.password);
const now = new Date();
const userId = crypto.randomUUID();
const now = new Date();
const userId = crypto.randomUUID();
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
...data,
id: userId,
name: data.name,
email: data.email,
emailVerified: true,
role: data.role,
createdAt: now,
updatedAt: now,
theme: data.theme as UserThemeEnum,
}).returning();
const [newUser] = await db
.insert(drizzleDb.schemas.user)
.values({
...data,
id: userId,
name: data.name,
email: data.email,
emailVerified: true,
role: data.role,
createdAt: now,
updatedAt: now,
theme: data.theme as UserThemeEnum,
})
.returning();
if (data.password) {
const hashedPassword = await hashPassword(data.password);
await db.insert(drizzleDb.schemas.account).values({
providerId: "credential",
accountId: userId,
userId: userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
});
}
if (data.password) {
const hashedPassword = await hashPassword(data.password);
await db.insert(drizzleDb.schemas.account).values({
providerId: "credential",
accountId: userId,
userId: userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
});
}
return newUser
return newUser;
}
+4
View File
@@ -88,6 +88,8 @@ export const env = createEnv({
ALLOWED_GROUP: z.string().optional(),
SKIP_ONBOARDING: z.string().optional().default("false"),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
@@ -174,6 +176,8 @@ export const env = createEnv({
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
SKIP_ONBOARDING: process.env.SKIP_ONBOARDING,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
@@ -1,6 +1,6 @@
"use server";
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/agents.schema";
import {AgentSchema} from "@/features/agents/schemas/agents.schema";
import {z} from "zod";
import {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db";
@@ -11,7 +11,7 @@ import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {useAgentUpdateCheck} from "@/features/agents/use-agent-update-check";
import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check";
export type agentCardProps = {
data: AgentWith;
@@ -3,10 +3,10 @@
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/features/agents/agent-card-key";
import {AgentCardKey} from "@/features/agents/components/agent-card-key";
import {AgentWithDatabases} from "@/db/schema/08_agent";
import {useQuery} from "@tanstack/react-query";
import {getAgentAction} from "@/features/agents/agents.action";
import {getAgentAction} from "@/features/agents/actions/agents.action";
import {
Accordion,
AccordionContent,
@@ -16,8 +16,8 @@ import {
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/features/agents/agent-database-card";
import {HealthCheckGraph} from "@/features/database/health-grid";
import {AgentDatabaseCard} from "@/features/agents/components/agent-database-card";
import {HealthCheckGraph} from "@/features/database/components/health-grid";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
type AgentContentPageProps = {
@@ -5,7 +5,7 @@ import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {deleteAgentAction} from "@/features/agents/agent-delete.action";
import {deleteAgentAction} from "@/features/agents/actions/agent-delete.action";
import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = {
@@ -7,7 +7,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {AgentForm} from "@/features/agents/agent-form";
import {AgentForm} from "@/features/agents/components/agent-form";
import {Button, buttonVariants} from "@/components/ui/button";
import {Plus} from "lucide-react";
import {GearIcon} from "@radix-ui/react-icons";
@@ -16,7 +16,7 @@ import {useState} from "react";
import {useRouter} from "next/navigation";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {AgentOrganisationForm} from "@/features/agents/agent-organizations.form";
import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form";
import {AgentWith} from "@/db/schema/08_agent";
type AgentDialogProps = {
@@ -15,9 +15,9 @@ import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import {AgentSchema, AgentType} from "@/features/agents/agents.schema";
import {AgentSchema, AgentType} from "@/features/agents/schemas/agents.schema";
import {toast} from "sonner";
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
import {createAgentAction, updateAgentAction} from "@/features/agents/actions/agents.action";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type agentFormProps = {
@@ -8,8 +8,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {MultiSelect} from "@/components/common/multi-select";
import {toast} from "sonner";
import {AgentWith} from "@/db/schema/08_agent";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/agent-organizations.action";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/schemas/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/actions/agent-organizations.action";
type AgentOrganisationFormProps = {
@@ -9,7 +9,7 @@ import { Form } from "@/components/ui/form";
import { requestPasswordReset } from "@/lib/auth/auth-client";
import { ButtonWithLoading } from "@/components/common/button-with-loading";
import Link from "next/link";
import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema";
import { ForgotPasswordSchema, ForgotPasswordType } from "../schemas/forgot-password-form.schema";
import { ArrowLeft } from "lucide-react";
export type ForgotPasswordFormProps = {
@@ -2,7 +2,7 @@
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
import TwoFactorForm from "@/features/profile/2fa-form";
import TwoFactorForm from "@/features/profile/components/2fa-form";
export const GuardForm = () => {
const router = useRouter();
@@ -19,7 +19,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import {
LoginSchema,
type LoginType,
} from "@/features/auth/login-form.schema";
} from "@/features/auth/schemas/login-form.schema";
import { ButtonWithLoading } from "@/components/common/button-with-loading";
import { signIn } from "@/lib/auth/auth-client";
@@ -14,10 +14,10 @@ import {
import {
RegisterSchema,
RegisterType,
} from "@/features/auth/register-form.schema";
} from "@/features/auth/schemas/register-form.schema";
import { signUp } from "@/lib/auth/auth-client";
import { useZodForm } from "@/components/ui/form";
import { CardAuth } from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/components/card-auth";
import { FormField } from "@/components/ui/form";
import { FormItem } from "@/components/ui/form";
import { FormLabel } from "@/components/ui/form";
@@ -7,7 +7,7 @@ import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/compone
import {Form} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
import Link from "next/link";
import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema";
import {ResetPasswordSchema, ResetPasswordType} from "../schemas/reset-password-form.schema";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {useRouter, useSearchParams} from "next/navigation";
import {ArrowLeft} from "lucide-react";
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient, passkey } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Icon } from "@iconify/react";
import { AuthProviderConfig } from "@/lib/auth/config";
import Image from "next/image";
import { useRouter } from "next/navigation";
export function SocialAuthButtons({
providers,
}: {
providers: AuthProviderConfig[];
}) {
const socialProviders = providers.filter(
(p) => p.isActive && p.type !== "credential",
);
const router = useRouter();
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
setIsLoading(provider.id);
try {
let result;
if (provider.id === "passkey") {
result = await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
router.push("/dashboard");
},
onError() {
toast.error(
"An error occurred during passkey authentication. Please try again.",
);
},
},
});
} else if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
} else {
result = await authClient.signIn.social({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: provider.id as any,
callbackURL: "/dashboard",
});
}
if (result?.error) {
toast.error(
"An error occurred while signing in with the provider. Please try again.",
);
} else if (provider.id !== "passkey") {
toast.success("Redirecting to provider...");
}
} catch (err) {
toast.error(
"An error occurred while signing in with the provider. Please try again.",
);
} finally {
setIsLoading(null);
}
};
if (socialProviders.length === 0) return null;
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button
key={provider.id}
variant="outline"
className="w-full gap-2"
onClick={() => handleSocialSignIn(provider)}
disabled={!!isLoading}
>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") ||
provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
}
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient, passkey } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Icon } from "@iconify/react";
import { AuthProviderConfig } from "@/lib/auth/config";
import Image from "next/image";
import { useRouter } from "next/navigation";
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential");
const router = useRouter();
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
setIsLoading(provider.id);
try {
let result;
if (provider.id === "passkey") {
result = await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
router.push("/dashboard");
},
onError() {
toast.error("An error occurred during passkey authentication. Please try again.");
},
},
});
} else if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
console.log(result);
} else {
result = await authClient.signIn.social({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: provider.id as any,
callbackURL: "/dashboard",
});
}
if (result?.error) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} else if (provider.id !== "passkey") {
toast.success("Redirecting to provider...");
}
} catch (err) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} finally {
setIsLoading(null);
}
};
if (socialProviders.length === 0) return null;
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider)} disabled={!!isLoading}>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
}
-88
View File
@@ -1,88 +0,0 @@
"use client";
import {Card} from "@/components/ui/card";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Badge} from "@/components/ui/badge";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
EditChannelButton
} from "@/features/channel/channel-edit-button";
import {ChannelKind, getChannelIcon} from "@/features/channel/channels-helpers";
import {
DeleteChannelButton
} from "@/features/channel/channel-delete-button";
export type ChannelCardProps = {
data: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind?: ChannelKind;
defaultStorageChannelId?: string | null | undefined
};
export const ChannelCard = (props: ChannelCardProps) => {
const {data, organization, kind, adminView, defaultStorageChannelId} = props;
const isMobile = useIsMobile()
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider == "local";
return (
<div className="block transition-all duration-200 rounded-xl">
<Card className="flex flex-row justify-between p-4">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getChannelIcon(data.provider)}
</div>
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
</div>
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
{isDefaultSystemStorage && (
<Badge variant="secondary" className="text-xs font-mono">
default
</Badge>
)}
</div>
</div>
{kind && (
<div className="flex items-center gap-2">
{(isOwned) && (
<>
<EditChannelButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
channel={data}
kind={kind}
/>
{!isLocalSystem && (
<DeleteChannelButton
kind={kind}
organizationId={organization?.id}
channelId={data.id}
/>
)}
</>
)}
</div>
)}
</Card>
</div>
);
};
@@ -12,11 +12,11 @@ import {useIsMobile} from "@/hooks/use-mobile";
import {useEffect, useState} from "react";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelForm} from "@/features/channel/channel-form";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/channels-helpers";
import {ChannelForm} from "@/features/channel/components/channel-form";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/components/channels-helpers";
import {
ChannelOrganisationForm
} from "@/features/organizations/channels-organization-form";
} from "@/features/organizations/components/channels-organization-form";
type ChannelAddModalProps = {
@@ -0,0 +1,86 @@
"use client";
import { Card } from "@/components/ui/card";
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
import { Badge } from "@/components/ui/badge";
import { OrganizationWithMembers } from "@/db/schema/03_organization";
import { truncateWords } from "@/utils/text";
import { useIsMobile } from "@/hooks/use-mobile";
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import { EditChannelButton } from "@/features/channel/components/channel-edit-button";
import {
ChannelKind,
getChannelIcon,
} from "@/features/channel/components/channels-helpers";
import { DeleteChannelButton } from "@/features/channel/components/channel-delete-button";
export type ChannelCardProps = {
data: NotificationChannelWith | StorageChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
kind?: ChannelKind;
defaultStorageChannelId?: string | null | undefined;
};
export const ChannelCard = (props: ChannelCardProps) => {
const { data, organization, kind, defaultStorageChannelId } = props;
const isMobile = useIsMobile();
const isDefaultSystemStorage = defaultStorageChannelId === data.id;
const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider === "local";
return (
<div className="block transition-all duration-200 rounded-xl">
<Card className="flex flex-row justify-between p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getChannelIcon(data.provider)}
</div>
<div
className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}
/>
</div>
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">
{isMobile ? truncateWords(data.name, 2) : data.name}
</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
{isDefaultSystemStorage && (
<Badge variant="secondary" className="text-xs font-mono">
default
</Badge>
)}
</div>
</div>
{kind && (
<div className="flex items-center gap-2">
{isOwned && (
<>
<EditChannelButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
channel={data}
kind={kind}
/>
{!isLocalSystem && (
<DeleteChannelButton
kind={kind}
organizationId={organization?.id}
channelId={data.id}
/>
)}
</>
)}
</div>
)}
</Card>
</div>
);
};
@@ -5,12 +5,12 @@ import {useRouter} from "next/navigation";
import {Trash2} from "lucide-react";
import {
removeNotificationChannelAction,
} from "@/features/channel/notifications/channel.action";
} from "@/features/channel/components/notifications/channel.action";
import {toast} from "sonner";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/channels-helpers";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/components/channels-helpers";
import {
removeStorageChannelAction
} from "@/features/channel/storages/channel.action";
} from "@/features/channel/components/storages/channel.action";
export type DeleteChannelButtonProps = {
channelId: string;
@@ -7,14 +7,14 @@ import {toast} from "sonner";
import {useState} from "react";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ChannelKind} from "@/features/channel/channels-helpers";
import {ChannelKind} from "@/features/channel/components/channels-helpers";
import {
updateNotificationChannelAction
} from "@/features/channel/notifications/channel.action";
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
} from "@/features/channel/components/notifications/channel.action";
import {ChannelAddEditModal} from "@/features/channel/components/channel-add-edit-modal";
import {
updateStorageChannelAction
} from "@/features/channel/storages/channel.action";
} from "@/features/channel/components/storages/channel.action";
export type EditChannelButtonProps = {
channel: NotificationChannelWith | StorageChannelWith;
@@ -8,7 +8,7 @@ import {Input} from "@/components/ui/input";
import {
addStorageChannelAction, updateStorageChannelAction
} from "@/features/channel/storages/channel.action";
} from "@/features/channel/components/storages/channel.action";
import {toast} from "sonner";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Button} from "@/components/ui/button";
@@ -20,19 +20,19 @@ import {ArrowLeft} from "lucide-react";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {
NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType
} from "@/features/channel/channel-form.schema";
} from "@/features/channel/schemas/channel-form.schema";
import {
ChannelKind,
renderChannelForm
} from "@/features/channel/channels-helpers";
import {storageProviders} from "@/features/channel/channels-storage-helper";
import {notificationProviders} from "@/features/channel/channels-notification-helper";
} from "@/features/channel/components/channels-helpers";
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
import {notificationProviders} from "@/features/channel/components/channels-notification-helper";
import {
ChannelTestButton
} from "@/features/channel/channel-test-button";
} from "@/features/channel/components/channel-test-button";
import {
addNotificationChannelAction, updateNotificationChannelAction
} from "@/features/channel/notifications/channel.action";
} from "@/features/channel/components/notifications/channel.action";
type NotifierFormProps = {
@@ -6,14 +6,14 @@ import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {dispatchNotification} from "@/features/notifications/notifications.dispatch";
import {EventPayload} from "@/features/notifications/notifications.types";
import {dispatchNotification} from "@/features/notifications/utils/notifications.dispatch";
import {EventPayload} from "@/features/notifications/types";
import {useIsMobile} from "@/hooks/use-mobile";
import {cn} from "@/lib/utils";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import {ChannelKind} from "@/features/channel/channels-helpers";
import type {StorageInput} from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch";
import {ChannelKind} from "@/features/channel/components/channels-helpers";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
type NotifierTestChannelButtonProps = {
@@ -1,49 +1,49 @@
import {UseFormReturn} from "react-hook-form";
import {
NotifierSmtpForm
} from "@/features/channel/notifications/smtp.form";
} from "@/features/channel/components/notifications/smtp.form";
import {
NotifierSlackForm
} from "@/features/channel/notifications/slack.form";
} from "@/features/channel/components/notifications/slack.form";
import {
NotifierDiscordForm
} from "@/features/channel/notifications/discord.form";
} from "@/features/channel/components/notifications/discord.form";
import {
NotifierTelegramForm
} from "@/features/channel/notifications/telegram.form";
} from "@/features/channel/components/notifications/telegram.form";
import {
NotifierGotifyForm
} from "@/features/channel/notifications/gotify.form";
} from "@/features/channel/components/notifications/gotify.form";
import {
NotifierNtfyForm
} from "@/features/channel/notifications/ntfy.form";
} from "@/features/channel/components/notifications/ntfy.form";
import {
NotifierWebhookForm
} from "@/features/channel/notifications/webhook.form";
} from "@/features/channel/components/notifications/webhook.form";
import {
NotifierNextcloudForm
} from "@/features/channel/notifications/nextcloud.form";
} from "@/features/channel/components/notifications/nextcloud.form";
import {
NotifierPushoverForm
} from "@/features/channel/notifications/pushover.form";
} from "@/features/channel/components/notifications/pushover.form";
import {
notificationProviders,
} from "@/features/channel/channels-notification-helper";
} from "@/features/channel/components/channels-notification-helper";
import {
NotifierTeamsForm
} from "@/features/channel/notifications/teams.form";
import {storageProviders} from "@/features/channel/channels-storage-helper";
} from "@/features/channel/components/notifications/teams.form";
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
import {LucideProps} from "lucide-react";
import {
StorageS3Form
} from "@/features/channel/storages/s3.form";
} from "@/features/channel/components/storages/s3.form";
import {
StorageGoogleDriveForm
} from "@/features/channel/storages/google-drive/google-drive.form";
} from "@/features/channel/components/storages/google-drive/google-drive.form";
import {
StorageBlobForm
} from "@/features/channel/storages/az-blob.form";
} from "@/features/channel/components/storages/az-blob.form";
export type ChannelKind = "notification" | "storage";
@@ -1,6 +1,6 @@
import {Mail} from "lucide-react";
import type { SVGProps } from 'react';
import {ProviderIconTypes} from "@/features/channel/channels-helpers";
import {ProviderIconTypes} from "@/features/channel/components/channels-helpers";
@@ -5,12 +5,12 @@ import { useState } from "react";
import { EmptyStatePlaceholder } from "@/components/common/empty-state-placeholder";
import { OrganizationWithMembers } from "@/db/schema/03_organization";
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
import { ChannelCard } from "@/features/channel/channel-card";
import { ChannelAddEditModal } from "@/features/channel/channel-add-edit-modal";
import { ChannelCard } from "@/features/channel/components/channel-card";
import { ChannelAddEditModal } from "@/features/channel/components/channel-add-edit-modal";
import {
ChannelKind,
getChannelTextBasedOnKind,
} from "@/features/channel/channels-helpers";
} from "@/features/channel/components/channels-helpers";
type ChannelsSectionProps = {
channels: NotificationChannelWith[] | StorageChannelWith[];
@@ -1,6 +1,6 @@
import {Server} from "lucide-react";
import type {SVGProps} from "react";
import {ProviderIconTypes} from "@/features/channel/channels-helpers";
import {ProviderIconTypes} from "@/features/channel/components/channels-helpers";
export const storageProviders: ProviderIconTypes[] = [
{value: "local", label: "Local", icon: Server},
@@ -0,0 +1,200 @@
"use server";
import { z } from "zod";
import { ServerActionResult } from "@/types/action-type";
import * as drizzleDb from "@/db";
import { userAction } from "@/lib/safe-actions/actions";
import { NotificationChannel } from "@/db/schema/09_notification-channel";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { NotificationChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
export const addNotificationChannelAction = userAction
.schema(
z.object({
organizationId: z.string().optional(),
data: NotificationChannelFormSchema,
}),
)
.action(
async ({
parsedInput,
}): Promise<ServerActionResult<NotificationChannel>> => {
const { organizationId, data } = parsedInput;
try {
const [channel] = await db
.insert(drizzleDb.schemas.notificationChannel)
.values({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
organizationId: organizationId ?? null,
})
.returning();
if (organizationId) {
await db
.insert(drizzleDb.schemas.organizationNotificationChannel)
.values({
organizationId,
notificationChannelId: channel.id,
});
}
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: "Notification channel has been successfully created.",
messageParams: { notificationChannelId: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to create notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { notificationChannelId: "" },
},
};
}
},
);
export const removeNotificationChannelAction = userAction
.schema(
z.object({
organizationId: z.string().optional(),
notificationChannelId: z.string(),
}),
)
.action(
async ({
parsedInput,
}): Promise<ServerActionResult<NotificationChannel>> => {
const { organizationId, notificationChannelId } = parsedInput;
try {
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationNotificationChannel)
.where(
and(
eq(
drizzleDb.schemas.organizationNotificationChannel
.organizationId,
organizationId,
),
eq(
drizzleDb.schemas.organizationNotificationChannel
.notificationChannelId,
notificationChannelId,
),
),
);
}
const [deletedChannel] = await db
.delete(drizzleDb.schemas.notificationChannel)
.where(
eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId),
)
.returning();
if (!deletedChannel) {
return {
success: false,
actionError: {
message: "Notification channel not found.",
status: 404,
messageParams: { notificationChannelId: notificationChannelId },
},
};
}
return {
success: true,
value: {
...deletedChannel,
config: deletedChannel.config as JSON,
},
actionSuccess: {
message: "Notification channel has been successfully removed.",
messageParams: { notificationChannelId: notificationChannelId },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to remove notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { notificationChannelId: notificationChannelId },
},
};
}
},
);
export const updateNotificationChannelAction = userAction
.schema(
z.object({
id: z.string(),
data: NotificationChannelFormSchema,
}),
)
.action(
async ({
parsedInput,
}): Promise<ServerActionResult<NotificationChannel>> => {
const { id, data } = parsedInput;
try {
const [channel] = await db
.update(drizzleDb.schemas.notificationChannel)
.set(
withUpdatedAt({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
}),
)
.where(eq(drizzleDb.schemas.notificationChannel.id, id))
.returning();
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: `Notification channel "${channel.name}" has been successfully updated.`,
messageParams: { id: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: "" },
},
};
}
},
);
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendDiscord(
config: { discordWebhook: string },
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendGotify(
config: { gotifyServerUrl: string; gotifyAppToken: string },
@@ -1,15 +1,15 @@
"use server"
import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {ProviderKind, EventPayload, DispatchResult} from '@/features/notifications/types';
import {sendSlack} from './slack';
import {sendSmtp} from './smtp';
import {sendDiscord} from "@/features/channel/notifications/discord";
import {sendTelegram} from "@/features/channel/notifications/telegram";
import {sendGotify} from "@/features/channel/notifications/gotify";
import {sendNtfy} from "@/features/channel/notifications/ntfy";
import {sendWebhook} from "@/features/channel/notifications/webhook";
import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
import {sendPushover} from "@/features/channel/notifications/pushover";
import {sendTeams} from "@/features/channel/notifications/teams"
import {sendDiscord} from "@/features/channel/components/notifications/discord";
import {sendTelegram} from "@/features/channel/components/notifications/telegram";
import {sendGotify} from "@/features/channel/components/notifications/gotify";
import {sendNtfy} from "@/features/channel/components/notifications/ntfy";
import {sendWebhook} from "@/features/channel/components/notifications/webhook";
import {sendNextcloud} from "@/features/channel/components/notifications/nextcloud";
import {sendPushover} from "@/features/channel/components/notifications/pushover";
import {sendTeams} from "@/features/channel/components/notifications/teams"
const handlers: Record<
ProviderKind,
@@ -1,5 +1,5 @@
import { createHmac, randomBytes } from "crypto";
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
type NextcloudConfig = {
nextcloudUrl: string;
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
const getPriority = (level?: string): number => {
switch (level) {
@@ -4,7 +4,7 @@ import {Input} from "@/components/ui/input";
import {Separator} from "@/components/ui/separator";
import {PasswordInput} from "@/components/ui/password-input";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {PUSHOVER_PRIORITIES} from "@/features/channel/notifications/pushover.schema";
import {PUSHOVER_PRIORITIES} from "@/features/channel/components/notifications/pushover.schema";
type NotifierPushoverFormProps = {
form: UseFormReturn<any, any, any>;
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendPushover(
config: {
@@ -1,4 +1,4 @@
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
import type {EventPayload, DispatchResult} from '@/features/notifications/types';
export async function sendSlack(
config: { slackWebhook: string },
@@ -1,7 +1,7 @@
"use server";
import { render } from "@react-email/render";
import nodemailer from "nodemailer";
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
import EmailNotification from "@/components/emails/email-notification";
export async function sendSmtp(
@@ -1,4 +1,4 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
@@ -1,4 +1,4 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
export async function sendTelegram(
config: {
@@ -1,4 +1,4 @@
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
type WebhookConfig = {
webhookUrl: string;
@@ -6,7 +6,7 @@ import {
StorageMetaData,
StorageResult,
StorageUploadInput
} from '@/features/storages/storages.types';
} from '@/features/storages/types';
import {Readable} from "node:stream";
type BlobConfig = {
@@ -0,0 +1,189 @@
"use server";
import { z } from "zod";
import { ServerActionResult } from "@/types/action-type";
import * as drizzleDb from "@/db";
import { userAction } from "@/lib/safe-actions/actions";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import { withUpdatedAt } from "@/db/utils";
import { StorageChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
import { StorageChannel } from "@/db/schema/12_storage-channel";
export const addStorageChannelAction = userAction
.schema(
z.object({
organizationId: z.string().optional(),
data: StorageChannelFormSchema,
}),
)
.action(
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
const { organizationId, data } = parsedInput;
try {
const [channel] = await db
.insert(drizzleDb.schemas.storageChannel)
.values({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
organizationId: organizationId ?? null,
})
.returning();
if (organizationId) {
await db.insert(drizzleDb.schemas.organizationStorageChannel).values({
organizationId,
storageChannelId: channel.id,
});
}
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: "Storage channel has been successfully created.",
messageParams: { id: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to create storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: "" },
},
};
}
},
);
export const removeStorageChannelAction = userAction
.schema(
z.object({
organizationId: z.string().optional(),
id: z.string(),
}),
)
.action(
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
const { organizationId, id } = parsedInput;
try {
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationStorageChannel)
.where(
and(
eq(
drizzleDb.schemas.organizationStorageChannel.organizationId,
organizationId,
),
eq(
drizzleDb.schemas.organizationStorageChannel.storageChannelId,
id,
),
),
);
}
const [deletedChannel] = await db
.delete(drizzleDb.schemas.storageChannel)
.where(eq(drizzleDb.schemas.storageChannel.id, id))
.returning();
if (!deletedChannel) {
return {
success: false,
actionError: {
message: "Storage channel not found.",
status: 404,
messageParams: { id: id },
},
};
}
return {
success: true,
value: {
...deletedChannel,
config: deletedChannel.config as JSON,
},
actionSuccess: {
message: "Storage channel has been successfully removed.",
messageParams: { id: id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to remove storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: id },
},
};
}
},
);
export const updateStorageChannelAction = userAction
.schema(
z.object({
id: z.string(),
data: StorageChannelFormSchema,
}),
)
.action(
async ({ parsedInput }): Promise<ServerActionResult<StorageChannel>> => {
const { id, data } = parsedInput;
try {
const [channel] = await db
.update(drizzleDb.schemas.storageChannel)
.set(
withUpdatedAt({
provider: data.provider,
name: data.name,
config: data.config,
enabled: data.enabled ?? true,
}),
)
.where(eq(drizzleDb.schemas.storageChannel.id, id))
.returning();
return {
success: true,
value: {
...channel,
config: channel.config as JSON,
},
actionSuccess: {
message: `Storage channel "${channel.name}" has been successfully updated.`,
messageParams: { id: channel.id },
},
};
} catch (_error) {
const error = _error;
return {
success: false,
actionError: {
message: "Failed to update storage channel.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { id: "" },
},
};
}
},
);

Some files were not shown because too many files have changed in this diff Show More