feat(webapp): Platform notifications admin imporovements (#3324)
- bugfix to show the changelog to the target audience - more functionality for admins, to edit, delete and archive notifications
This commit is contained in:
@@ -33,13 +33,17 @@ import { Badge } from "../primitives/Badge";
|
||||
export function HelpAndFeedback({
|
||||
disableShortcut = false,
|
||||
isCollapsed = false,
|
||||
organizationId,
|
||||
projectId,
|
||||
}: {
|
||||
disableShortcut?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
}) {
|
||||
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
|
||||
const currentPlan = useCurrentPlan();
|
||||
const { changelogs } = useRecentChangelogs();
|
||||
const { changelogs } = useRecentChangelogs(organizationId, projectId);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
|
||||
|
||||
@@ -205,7 +205,7 @@ export function OrganizationSettingsSideMenu({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<HelpAndFeedback organizationId={organization.id} />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -713,7 +713,7 @@ export function SideMenu({
|
||||
isCollapsed && "items-center"
|
||||
)}
|
||||
>
|
||||
<HelpAndAI isCollapsed={isCollapsed} />
|
||||
<HelpAndAI isCollapsed={isCollapsed} organizationId={organization.id} projectId={project.id} />
|
||||
{isFreeUser && (
|
||||
<CollapsibleHeight isCollapsed={isCollapsed}>
|
||||
<FreePlanUsage
|
||||
@@ -1163,7 +1163,7 @@ function CollapsibleHeight({
|
||||
);
|
||||
}
|
||||
|
||||
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
function HelpAndAI({ isCollapsed, organizationId, projectId }: { isCollapsed: boolean; organizationId: string; projectId: string }) {
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div
|
||||
@@ -1173,7 +1173,7 @@ function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
)}
|
||||
>
|
||||
<ShortcutsAutoOpen />
|
||||
<HelpAndFeedback isCollapsed={isCollapsed} />
|
||||
<HelpAndFeedback isCollapsed={isCollapsed} organizationId={organizationId} projectId={projectId} />
|
||||
<AskAI isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { getRecentChangelogs } from "~/services/platformNotifications.server";
|
||||
import { getRecentChangelogs, verifyOrgMembership } from "~/services/platformNotifications.server";
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => false;
|
||||
|
||||
@@ -12,24 +12,37 @@ export type PlatformChangelogsLoaderData = {
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
const userId = await requireUserId(request);
|
||||
const url = new URL(request.url);
|
||||
const rawOrganizationId = url.searchParams.get("organizationId") ?? undefined;
|
||||
const rawProjectId = url.searchParams.get("projectId") ?? undefined;
|
||||
|
||||
const changelogs = await getRecentChangelogs();
|
||||
const { organizationId, projectId } = await verifyOrgMembership({
|
||||
userId,
|
||||
organizationId: rawOrganizationId,
|
||||
projectId: rawProjectId,
|
||||
});
|
||||
|
||||
const changelogs = await getRecentChangelogs({ userId, organizationId, projectId });
|
||||
|
||||
return json<PlatformChangelogsLoaderData>({ changelogs });
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
export function useRecentChangelogs() {
|
||||
export function useRecentChangelogs(organizationId?: string, projectId?: string) {
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const hasInitiallyFetched = useRef(false);
|
||||
const lastLoadedUrl = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = "/resources/platform-changelogs";
|
||||
const params = new URLSearchParams();
|
||||
if (organizationId) params.set("organizationId", organizationId);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const qs = params.toString();
|
||||
const url = `/resources/platform-changelogs${qs ? `?${qs}` : ""}`;
|
||||
|
||||
if (!hasInitiallyFetched.current && fetcher.state === "idle") {
|
||||
hasInitiallyFetched.current = true;
|
||||
if (lastLoadedUrl.current !== url && fetcher.state === "idle") {
|
||||
lastLoadedUrl.current = url;
|
||||
fetcher.load(url);
|
||||
}
|
||||
|
||||
@@ -40,7 +53,7 @@ export function useRecentChangelogs() {
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [organizationId, projectId]);
|
||||
|
||||
return {
|
||||
changelogs: fetcher.data?.changelogs ?? [],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef } from "react";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
getActivePlatformNotifications,
|
||||
verifyOrgMembership,
|
||||
type PlatformNotificationWithPayload,
|
||||
} from "~/services/platformNotifications.server";
|
||||
|
||||
@@ -18,8 +19,14 @@ export type PlatformNotificationsLoaderData = {
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
const projectId = url.searchParams.get("projectId") ?? undefined;
|
||||
const rawOrganizationId = url.searchParams.get("organizationId") ?? undefined;
|
||||
const rawProjectId = url.searchParams.get("projectId") ?? undefined;
|
||||
|
||||
const { organizationId, projectId } = await verifyOrgMembership({
|
||||
userId,
|
||||
organizationId: rawOrganizationId,
|
||||
projectId: rawProjectId,
|
||||
});
|
||||
|
||||
if (!organizationId) {
|
||||
return json<PlatformNotificationsLoaderData>({ notifications: [], unreadCount: 0 });
|
||||
|
||||
@@ -59,13 +59,15 @@ export type PlatformNotificationWithPayload = {
|
||||
export async function getAdminNotificationsList({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
hideArchived = false,
|
||||
hideInactive = false,
|
||||
}: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
hideArchived?: boolean;
|
||||
hideInactive?: boolean;
|
||||
}) {
|
||||
const where = hideArchived ? { archivedAt: null } : {};
|
||||
const where = hideInactive
|
||||
? { archivedAt: null, endsAt: { gt: new Date() } }
|
||||
: {};
|
||||
|
||||
const [notifications, total] = await Promise.all([
|
||||
prisma.platformNotification.findMany({
|
||||
@@ -98,6 +100,9 @@ export async function getAdminNotificationsList({
|
||||
title: n.title,
|
||||
surface: n.surface,
|
||||
scope: n.scope,
|
||||
userId: n.userId,
|
||||
organizationId: n.organizationId,
|
||||
projectId: n.projectId,
|
||||
priority: n.priority,
|
||||
startsAt: n.startsAt,
|
||||
endsAt: n.endsAt,
|
||||
@@ -109,6 +114,8 @@ export async function getAdminNotificationsList({
|
||||
payloadDescription: parsed.success ? parsed.data.data.description : null,
|
||||
payloadActionUrl: parsed.success ? parsed.data.data.actionUrl : null,
|
||||
payloadImage: parsed.success ? parsed.data.data.image : null,
|
||||
payloadDismissOnAction: parsed.success ? (parsed.data.data.dismissOnAction ?? false) : false,
|
||||
payloadDiscovery: parsed.success ? (parsed.data.data.discovery ?? null) : null,
|
||||
cliMaxShowCount: n.cliMaxShowCount,
|
||||
cliMaxDaysAfterFirstSeen: n.cliMaxDaysAfterFirstSeen,
|
||||
cliShowEvery: n.cliShowEvery,
|
||||
@@ -280,16 +287,66 @@ export async function recordNotificationClicked({
|
||||
});
|
||||
}
|
||||
|
||||
// --- Membership verification ---
|
||||
|
||||
export async function verifyOrgMembership({
|
||||
userId,
|
||||
organizationId,
|
||||
projectId,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
}): Promise<{ organizationId?: string; projectId?: string }> {
|
||||
if (!organizationId) return {};
|
||||
|
||||
const membership = await prisma.orgMember.findFirst({
|
||||
where: { userId, organizationId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
if (!membership) return {};
|
||||
|
||||
if (projectId) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: projectId, organizationId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) return { organizationId };
|
||||
}
|
||||
|
||||
return { organizationId, projectId };
|
||||
}
|
||||
|
||||
// --- Read: recent changelogs (for Help & Feedback) ---
|
||||
|
||||
export async function getRecentChangelogs({ limit = 2 }: { limit?: number } = {}) {
|
||||
// NOTE: Intentionally not filtering by archivedAt, startsAt, or endsAt.
|
||||
export async function getRecentChangelogs({
|
||||
userId,
|
||||
organizationId,
|
||||
projectId,
|
||||
limit = 2,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
// NOTE: Intentionally not filtering by archivedAt or endsAt.
|
||||
// We want to show archived and expired changelogs in the "What's new" section
|
||||
// so users can still find recent release notes.
|
||||
// We DO filter by scope (to prevent user-scoped changelogs leaking to others)
|
||||
// and by startsAt (to hide changelogs scheduled for the future).
|
||||
const notifications = await prisma.platformNotification.findMany({
|
||||
where: {
|
||||
surface: "WEBAPP",
|
||||
payload: { path: ["data", "type"], equals: "changelog" },
|
||||
startsAt: { lte: new Date() },
|
||||
OR: [
|
||||
{ scope: "GLOBAL" },
|
||||
{ scope: "USER", userId },
|
||||
...(organizationId ? [{ scope: "ORGANIZATION" as const, organizationId }] : []),
|
||||
...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []),
|
||||
],
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: limit,
|
||||
@@ -497,28 +554,32 @@ const SCOPE_REQUIRED_FK: Record<string, "userId" | "organizationId" | "projectId
|
||||
const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const;
|
||||
const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount", "cliShowEvery"] as const;
|
||||
|
||||
const NotificationBaseFields = {
|
||||
title: z.string().min(1),
|
||||
payload: PayloadV1Schema,
|
||||
surface: z.enum(["WEBAPP", "CLI"]),
|
||||
scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]),
|
||||
userId: z.string().optional(),
|
||||
organizationId: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
endsAt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((s) => new Date(s)),
|
||||
priority: z.number().int().default(0),
|
||||
cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(),
|
||||
cliMaxShowCount: z.number().int().positive().optional(),
|
||||
cliShowEvery: z.number().int().min(2).optional(),
|
||||
};
|
||||
|
||||
export const CreatePlatformNotificationSchema = z
|
||||
.object({
|
||||
title: z.string().min(1),
|
||||
payload: PayloadV1Schema,
|
||||
surface: z.enum(["WEBAPP", "CLI"]),
|
||||
scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]),
|
||||
userId: z.string().optional(),
|
||||
organizationId: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
...NotificationBaseFields,
|
||||
startsAt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
endsAt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((s) => new Date(s)),
|
||||
priority: z.number().int().default(0),
|
||||
cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(),
|
||||
cliMaxShowCount: z.number().int().positive().optional(),
|
||||
cliShowEvery: z.number().int().min(2).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
validateScopeForeignKeys(data, ctx);
|
||||
@@ -619,6 +680,25 @@ function validateEndsAt(data: { startsAt?: Date; endsAt: Date }, ctx: z.Refineme
|
||||
|
||||
export type CreatePlatformNotificationInput = z.input<typeof CreatePlatformNotificationSchema>;
|
||||
|
||||
// --- Update: admin endpoint support ---
|
||||
|
||||
export const UpdatePlatformNotificationSchema = z
|
||||
.object({
|
||||
...NotificationBaseFields,
|
||||
id: z.string().min(1),
|
||||
startsAt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((s) => new Date(s)),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
validateScopeForeignKeys(data, ctx);
|
||||
validateSurfaceFields(data, ctx);
|
||||
validatePayloadTypeForSurface(data, ctx);
|
||||
// NOTE: No validateStartsAt — existing notifications may have past startsAt
|
||||
validateEndsAt(data, ctx);
|
||||
});
|
||||
|
||||
type CreateError =
|
||||
| { type: "validation"; issues: z.ZodIssue[] }
|
||||
| { type: "db"; message: string };
|
||||
@@ -659,3 +739,59 @@ export function createPlatformNotification(
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePlatformNotification(
|
||||
input: z.input<typeof UpdatePlatformNotificationSchema>
|
||||
): ResultAsync<{ id: string; friendlyId: string }, CreateError> {
|
||||
const parseResult = UpdatePlatformNotificationSchema.safeParse(input);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return errAsync({ type: "validation", issues: parseResult.error.issues });
|
||||
}
|
||||
|
||||
const data = parseResult.data;
|
||||
|
||||
return fromPromise(
|
||||
prisma.platformNotification.update({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
title: data.title,
|
||||
payload: data.payload,
|
||||
surface: data.surface as PlatformNotificationSurface,
|
||||
scope: data.scope as PlatformNotificationScope,
|
||||
userId: data.scope === "USER" ? data.userId : null,
|
||||
organizationId: data.scope === "ORGANIZATION" ? data.organizationId : null,
|
||||
projectId: data.scope === "PROJECT" ? data.projectId : null,
|
||||
startsAt: data.startsAt,
|
||||
endsAt: data.endsAt,
|
||||
priority: data.priority,
|
||||
cliMaxDaysAfterFirstSeen: data.surface === "CLI" ? (data.cliMaxDaysAfterFirstSeen ?? null) : null,
|
||||
cliMaxShowCount: data.surface === "CLI" ? (data.cliMaxShowCount ?? null) : null,
|
||||
cliShowEvery: data.surface === "CLI" ? (data.cliShowEvery ?? null) : null,
|
||||
},
|
||||
select: { id: true, friendlyId: true },
|
||||
}),
|
||||
(e): CreateError => ({
|
||||
type: "db",
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePlatformNotification(id: string): Promise<void> {
|
||||
await prisma.platformNotification.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function publishNowPlatformNotification(id: string): Promise<void> {
|
||||
await prisma.platformNotification.update({
|
||||
where: { id },
|
||||
data: { startsAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function archivePlatformNotification(id: string): Promise<void> {
|
||||
await prisma.platformNotification.update({
|
||||
where: { id },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user