mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
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:
co-authored by
Théo LAGACHE
charles-gauthereau
parent
40d3228db6
commit
40247efddd
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+4
-4
@@ -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 = {
|
||||
+1
-1
@@ -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 = {
|
||||
+2
-2
@@ -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 = {
|
||||
+2
-2
@@ -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 = {
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+2
-2
@@ -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";
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -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;
|
||||
+4
-4
@@ -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;
|
||||
+7
-7
@@ -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 = {
|
||||
+5
-5
@@ -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 = {
|
||||
+15
-15
@@ -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
-1
@@ -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";
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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 },
|
||||
+9
-9
@@ -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
-1
@@ -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
-1
@@ -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) {
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/types';
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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;
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user