Add app opens & services activity charts to admin dashboard
- New `app_opens` table (id, user_id, app_id, created_at) and Drizzle
schema; exported from lib/db schema index.
- New `POST /api/apps/{id}/open` endpoint logs an open for the current
user (auth required, 204 on success, 404 for unknown app id).
- Extended `GET /api/stats/admin` with appOpensByDay/appOpensLast7Days/
appOpensPrev7Days and servicesCreatedByDay/servicesCreatedLast7Days,
refactored around a shared buildSeries helper.
- Regenerated api-client/zod and added two new bar charts (App opens,
Services added) to the admin Dashboard alongside the existing
Sign-ups chart, with EN/AR translations.
- Home page fires bare `logAppOpen(id, { keepalive: true })` before
navigating so the request survives client-side route changes; the
bottom dock buttons also use this helper.
- Restructured SortableAppIcon so the click target and dnd-kit
listeners live on the same <button>, fixing a click-vs-drag
interaction that prevented the open event from firing in tests.
Rebase notes:
- home.tsx: incoming main reworked AppIcon styling, the apps grid
header (with count and empty state), and the dock button. Kept all
incoming visual changes while preserving this task's
AppIconContent fragment, single-button SortableAppIcon, and
openApp() wiring (so dock + grid both log opens).
- opengraph.jpg: kept incoming binary version.
E2E verified: clicking app icons increments app_opens; admin dashboard
renders all three charts.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
rolePermissionsTable,
|
rolePermissionsTable,
|
||||||
rolesTable,
|
rolesTable,
|
||||||
userAppOrdersTable,
|
userAppOrdersTable,
|
||||||
|
appOpensTable,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||||
import {
|
import {
|
||||||
@@ -187,6 +188,28 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
res.json(app);
|
res.json(app);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/apps/:id/open", requireAuth, async (req, res): Promise<void> => {
|
||||||
|
const userId = req.session.userId!;
|
||||||
|
const params = GetAppParams.safeParse(req.params);
|
||||||
|
if (!params.success) {
|
||||||
|
res.status(400).json({ error: params.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [app] = await db
|
||||||
|
.select({ id: appsTable.id })
|
||||||
|
.from(appsTable)
|
||||||
|
.where(eq(appsTable.id, params.data.id));
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
res.status(404).json({ error: "App not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(appOpensTable).values({ userId, appId: app.id });
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||||
const params = DeleteAppParams.safeParse(req.params);
|
const params = DeleteAppParams.safeParse(req.params);
|
||||||
if (!params.success) {
|
if (!params.success) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
usersTable,
|
usersTable,
|
||||||
messagesTable,
|
messagesTable,
|
||||||
messageReadsTable,
|
messageReadsTable,
|
||||||
|
appOpensTable,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||||
|
|
||||||
@@ -165,12 +166,71 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
|
|||||||
signupsByDay.push({ date: key, count: countsByDay.get(key) ?? 0 });
|
signupsByDay.push({ date: key, count: countsByDay.get(key) ?? 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildSeries = (
|
||||||
|
rows: { day: string; count: number }[],
|
||||||
|
): { date: string; count: number }[] => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const r of rows) map.set(r.day, Number(r.count));
|
||||||
|
const out: { date: string; count: number }[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
|
||||||
|
const key = d.toISOString().slice(0, 10);
|
||||||
|
out.push({ date: key, count: map.get(key) ?? 0 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appOpenRows = await db
|
||||||
|
.select({
|
||||||
|
day: sql<string>`to_char(date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(appOpensTable)
|
||||||
|
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||||
|
.groupBy(sql`date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||||
|
const appOpensByDay = buildSeries(appOpenRows);
|
||||||
|
|
||||||
|
const [appOpensLast7Row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(appOpensTable)
|
||||||
|
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||||
|
|
||||||
|
const [appOpensPrev7Row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(appOpensTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${appOpensTable.createdAt} >= ${prev7Start.toISOString()}`,
|
||||||
|
sql`${appOpensTable.createdAt} < ${last7Start.toISOString()}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const servicesCreatedRows = await db
|
||||||
|
.select({
|
||||||
|
day: sql<string>`to_char(date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(servicesTable)
|
||||||
|
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||||
|
.groupBy(sql`date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||||
|
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
|
||||||
|
|
||||||
|
const [servicesCreatedLast7Row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(servicesTable)
|
||||||
|
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
|
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
|
||||||
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
|
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
|
||||||
activeServices: Number(activeServicesRow?.count ?? 0),
|
activeServices: Number(activeServicesRow?.count ?? 0),
|
||||||
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
|
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
|
||||||
signupsByDay,
|
signupsByDay,
|
||||||
|
appOpensByDay,
|
||||||
|
appOpensLast7Days: Number(appOpensLast7Row?.count ?? 0),
|
||||||
|
appOpensPrev7Days: Number(appOpensPrev7Row?.count ?? 0),
|
||||||
|
servicesCreatedByDay,
|
||||||
|
servicesCreatedLast7Days: Number(servicesCreatedLast7Row?.count ?? 0),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -150,7 +150,11 @@
|
|||||||
"activeServices": "الخدمات النشطة",
|
"activeServices": "الخدمات النشطة",
|
||||||
"inactiveServices": "غير نشطة: {{count}}",
|
"inactiveServices": "غير نشطة: {{count}}",
|
||||||
"signupsTrend": "التسجيلات",
|
"signupsTrend": "التسجيلات",
|
||||||
"last7Days": "آخر ٧ أيام"
|
"last7Days": "آخر ٧ أيام",
|
||||||
|
"appOpensTrend": "فتح التطبيقات",
|
||||||
|
"appOpensSummary": "{{count}} إجمالاً · {{delta}}",
|
||||||
|
"servicesCreatedTrend": "خدمات جديدة",
|
||||||
|
"servicesCreatedSummary": "{{count}} مضافة"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
|
|||||||
@@ -150,7 +150,11 @@
|
|||||||
"activeServices": "Active services",
|
"activeServices": "Active services",
|
||||||
"inactiveServices": "Inactive: {{count}}",
|
"inactiveServices": "Inactive: {{count}}",
|
||||||
"signupsTrend": "Sign-ups",
|
"signupsTrend": "Sign-ups",
|
||||||
"last7Days": "Last 7 days"
|
"last7Days": "Last 7 days",
|
||||||
|
"appOpensTrend": "App opens",
|
||||||
|
"appOpensSummary": "{{count}} total · {{delta}}",
|
||||||
|
"servicesCreatedTrend": "Services added",
|
||||||
|
"servicesCreatedSummary": "{{count}} added"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
|
|||||||
@@ -808,6 +808,15 @@ function DashboardSection({
|
|||||||
const inactiveServices = adminStats?.inactiveServices ?? 0;
|
const inactiveServices = adminStats?.inactiveServices ?? 0;
|
||||||
const signups = adminStats?.signupsByDay ?? [];
|
const signups = adminStats?.signupsByDay ?? [];
|
||||||
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
|
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
|
||||||
|
const opens = adminStats?.appOpensByDay ?? [];
|
||||||
|
const maxOpens = Math.max(1, ...opens.map((s) => s.count));
|
||||||
|
const opensTotal = adminStats?.appOpensLast7Days ?? 0;
|
||||||
|
const opensPrev = adminStats?.appOpensPrev7Days ?? 0;
|
||||||
|
const opensDelta = opensTotal - opensPrev;
|
||||||
|
const opensDeltaPct = opensPrev > 0 ? Math.round((opensDelta / opensPrev) * 100) : null;
|
||||||
|
const servicesCreated = adminStats?.servicesCreatedByDay ?? [];
|
||||||
|
const maxServicesCreated = Math.max(1, ...servicesCreated.map((s) => s.count));
|
||||||
|
const servicesCreatedTotal = adminStats?.servicesCreatedLast7Days ?? 0;
|
||||||
const dayLabel = (iso: string) => {
|
const dayLabel = (iso: string) => {
|
||||||
try {
|
try {
|
||||||
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
|
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
|
||||||
@@ -816,6 +825,40 @@ function DashboardSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderChart = (
|
||||||
|
title: string,
|
||||||
|
series: { date: string; count: number }[],
|
||||||
|
max: number,
|
||||||
|
accent: string,
|
||||||
|
subtitle?: string,
|
||||||
|
) => (
|
||||||
|
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-sm font-semibold text-foreground truncate">{title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground shrink-0">{subtitle ?? t("admin.dashboard.last7Days")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-between gap-1.5 h-28 px-1" dir="ltr">
|
||||||
|
{series.map((s) => {
|
||||||
|
const heightPct = (s.count / max) * 100;
|
||||||
|
return (
|
||||||
|
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
||||||
|
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
|
||||||
|
<div className="w-full flex-1 flex items-end">
|
||||||
|
<div
|
||||||
|
className={cn("w-full rounded-t-md transition-all bg-gradient-to-t", accent)}
|
||||||
|
style={{ height: `${Math.max(heightPct, s.count > 0 ? 6 : 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
|
||||||
|
{dayLabel(s.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
@@ -880,32 +923,36 @@ function DashboardSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
{renderChart(
|
||||||
<div className="flex items-center justify-between">
|
t("admin.dashboard.signupsTrend"),
|
||||||
<div className="text-sm font-semibold text-foreground">
|
signups,
|
||||||
{t("admin.dashboard.signupsTrend")}
|
maxSignup,
|
||||||
</div>
|
"from-primary/40 to-primary",
|
||||||
<div className="text-xs text-muted-foreground">{t("admin.dashboard.last7Days")}</div>
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex items-end justify-between gap-1.5 h-28 px-1" dir="ltr">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{signups.map((s) => {
|
{renderChart(
|
||||||
const heightPct = (s.count / maxSignup) * 100;
|
t("admin.dashboard.appOpensTrend"),
|
||||||
return (
|
opens,
|
||||||
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
maxOpens,
|
||||||
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
|
"from-sky-300/40 to-sky-500",
|
||||||
<div className="w-full flex-1 flex items-end">
|
t("admin.dashboard.appOpensSummary", {
|
||||||
<div
|
count: opensTotal,
|
||||||
className="w-full rounded-t-md bg-gradient-to-t from-primary/40 to-primary transition-all"
|
delta:
|
||||||
style={{ height: `${Math.max(heightPct, s.count > 0 ? 6 : 2)}%` }}
|
opensDelta === 0
|
||||||
/>
|
? "±0"
|
||||||
</div>
|
: opensDeltaPct !== null
|
||||||
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
|
? `${opensDelta > 0 ? "+" : ""}${opensDeltaPct}%`
|
||||||
{dayLabel(s.date)}
|
: `${opensDelta > 0 ? "+" : ""}${opensDelta}`,
|
||||||
</div>
|
}),
|
||||||
</div>
|
)}
|
||||||
);
|
{renderChart(
|
||||||
})}
|
t("admin.dashboard.servicesCreatedTrend"),
|
||||||
</div>
|
servicesCreated,
|
||||||
|
maxServicesCreated,
|
||||||
|
"from-amber-300/40 to-amber-500",
|
||||||
|
t("admin.dashboard.servicesCreatedSummary", { count: servicesCreatedTotal }),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useLogout,
|
useLogout,
|
||||||
useUpdateLanguage,
|
useUpdateLanguage,
|
||||||
useUpdateMyAppOrder,
|
useUpdateMyAppOrder,
|
||||||
|
logAppOpen,
|
||||||
getGetMeQueryKey,
|
getGetMeQueryKey,
|
||||||
type App,
|
type App,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
@@ -78,7 +79,7 @@ function gradientForColor(color: string): string {
|
|||||||
return "icon-tile-blue";
|
return "icon-tile-blue";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; iconName: string; color: string; route: string }; onClick: () => void }) {
|
function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconName: string; color: string } }) {
|
||||||
const { i18n: i18nInstance } = useTranslation();
|
const { i18n: i18nInstance } = useTranslation();
|
||||||
const lang = i18nInstance.language;
|
const lang = i18nInstance.language;
|
||||||
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
||||||
@@ -86,17 +87,14 @@ function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; icon
|
|||||||
const Icon = resolveIcon(app.iconName);
|
const Icon = resolveIcon(app.iconName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<>
|
||||||
onClick={onClick}
|
|
||||||
className="flex flex-col items-center gap-2 group select-none"
|
|
||||||
>
|
|
||||||
<div className={`icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
<div className={`icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
||||||
<Icon size={30} color="#FFFFFF" />
|
<Icon size={30} color="#FFFFFF" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
|
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,9 +108,17 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
|
|||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
<button
|
||||||
<AppIcon app={app} onClick={onClick} />
|
type="button"
|
||||||
</div>
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex flex-col items-center gap-2 group"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<AppIconContent app={app} />
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +205,15 @@ export default function HomePage() {
|
|||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const updateLanguage = useUpdateLanguage();
|
const updateLanguage = useUpdateLanguage();
|
||||||
|
|
||||||
|
const openApp = (app: App) => {
|
||||||
|
// Fire-and-forget: keepalive ensures the request survives any
|
||||||
|
// imminent navigation triggered by setLocation below.
|
||||||
|
logAppOpen(app.id, { keepalive: true }).catch(() => {
|
||||||
|
// Best-effort tracking; ignore failures so navigation isn't blocked.
|
||||||
|
});
|
||||||
|
setLocation(app.route);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
@@ -373,7 +388,7 @@ export default function HomePage() {
|
|||||||
<SortableAppIcon
|
<SortableAppIcon
|
||||||
key={app.id}
|
key={app.id}
|
||||||
app={app}
|
app={app}
|
||||||
onClick={() => setLocation(app.route)}
|
onClick={() => openApp(app)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -392,7 +407,7 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={app.id}
|
key={app.id}
|
||||||
onClick={() => setLocation(app.route)}
|
onClick={() => openApp(app)}
|
||||||
className="relative flex flex-col items-center gap-1 group"
|
className="relative flex flex-col items-center gap-1 group"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -323,10 +323,25 @@ export type AdminStatsSignupsByDayItem = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminStatsAppOpensByDayItem = {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminStatsServicesCreatedByDayItem = {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
newUsersLast7Days: number;
|
newUsersLast7Days: number;
|
||||||
newUsersPrev7Days: number;
|
newUsersPrev7Days: number;
|
||||||
activeServices: number;
|
activeServices: number;
|
||||||
inactiveServices: number;
|
inactiveServices: number;
|
||||||
signupsByDay: AdminStatsSignupsByDayItem[];
|
signupsByDay: AdminStatsSignupsByDayItem[];
|
||||||
|
appOpensByDay: AdminStatsAppOpensByDayItem[];
|
||||||
|
appOpensLast7Days: number;
|
||||||
|
appOpensPrev7Days: number;
|
||||||
|
servicesCreatedByDay: AdminStatsServicesCreatedByDayItem[];
|
||||||
|
servicesCreatedLast7Days: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -930,6 +930,90 @@ export const useDeleteApp = <
|
|||||||
return useMutation(getDeleteAppMutationOptions(options));
|
return useMutation(getDeleteAppMutationOptions(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Log an app open event for the current user
|
||||||
|
*/
|
||||||
|
export const getLogAppOpenUrl = (id: number) => {
|
||||||
|
return `/api/apps/${id}/open`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logAppOpen = async (
|
||||||
|
id: number,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<void> => {
|
||||||
|
return customFetch<void>(getLogAppOpenUrl(id), {
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLogAppOpenMutationOptions = <
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof logAppOpen>>,
|
||||||
|
TError,
|
||||||
|
{ id: number },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof logAppOpen>>,
|
||||||
|
TError,
|
||||||
|
{ id: number },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["logAppOpen"];
|
||||||
|
const { mutation: mutationOptions, request: requestOptions } = options
|
||||||
|
? options.mutation &&
|
||||||
|
"mutationKey" in options.mutation &&
|
||||||
|
options.mutation.mutationKey
|
||||||
|
? options
|
||||||
|
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||||
|
: { mutation: { mutationKey }, request: undefined };
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<
|
||||||
|
Awaited<ReturnType<typeof logAppOpen>>,
|
||||||
|
{ id: number }
|
||||||
|
> = (props) => {
|
||||||
|
const { id } = props ?? {};
|
||||||
|
|
||||||
|
return logAppOpen(id, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogAppOpenMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof logAppOpen>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type LogAppOpenMutationError = ErrorType<ErrorResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Log an app open event for the current user
|
||||||
|
*/
|
||||||
|
export const useLogAppOpen = <
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof logAppOpen>>,
|
||||||
|
TError,
|
||||||
|
{ id: number },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof logAppOpen>>,
|
||||||
|
TError,
|
||||||
|
{ id: number },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getLogAppOpenMutationOptions(options));
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Set the current user's preferred home apps order
|
* @summary Set the current user's preferred home apps order
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -236,6 +236,27 @@ paths:
|
|||||||
"204":
|
"204":
|
||||||
description: Deleted
|
description: Deleted
|
||||||
|
|
||||||
|
/apps/{id}/open:
|
||||||
|
post:
|
||||||
|
operationId: logAppOpen
|
||||||
|
tags: [apps]
|
||||||
|
summary: Log an app open event for the current user
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Logged
|
||||||
|
"404":
|
||||||
|
description: App not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/me/app-order:
|
/me/app-order:
|
||||||
put:
|
put:
|
||||||
operationId: updateMyAppOrder
|
operationId: updateMyAppOrder
|
||||||
@@ -1336,9 +1357,44 @@ components:
|
|||||||
required:
|
required:
|
||||||
- date
|
- date
|
||||||
- count
|
- count
|
||||||
|
appOpensByDay:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- date
|
||||||
|
- count
|
||||||
|
appOpensLast7Days:
|
||||||
|
type: integer
|
||||||
|
appOpensPrev7Days:
|
||||||
|
type: integer
|
||||||
|
servicesCreatedByDay:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- date
|
||||||
|
- count
|
||||||
|
servicesCreatedLast7Days:
|
||||||
|
type: integer
|
||||||
required:
|
required:
|
||||||
- newUsersLast7Days
|
- newUsersLast7Days
|
||||||
- newUsersPrev7Days
|
- newUsersPrev7Days
|
||||||
- activeServices
|
- activeServices
|
||||||
- inactiveServices
|
- inactiveServices
|
||||||
- signupsByDay
|
- signupsByDay
|
||||||
|
- appOpensByDay
|
||||||
|
- appOpensLast7Days
|
||||||
|
- appOpensPrev7Days
|
||||||
|
- servicesCreatedByDay
|
||||||
|
- servicesCreatedLast7Days
|
||||||
|
|||||||
@@ -197,6 +197,13 @@ export const DeleteAppParams = zod.object({
|
|||||||
id: zod.coerce.number(),
|
id: zod.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Log an app open event for the current user
|
||||||
|
*/
|
||||||
|
export const LogAppOpenParams = zod.object({
|
||||||
|
id: zod.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Set the current user's preferred home apps order
|
* @summary Set the current user's preferred home apps order
|
||||||
*/
|
*/
|
||||||
@@ -721,4 +728,19 @@ export const GetAdminStatsResponse = zod.object({
|
|||||||
count: zod.number(),
|
count: zod.number(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
appOpensByDay: zod.array(
|
||||||
|
zod.object({
|
||||||
|
date: zod.string(),
|
||||||
|
count: zod.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
appOpensLast7Days: zod.number(),
|
||||||
|
appOpensPrev7Days: zod.number(),
|
||||||
|
servicesCreatedByDay: zod.array(
|
||||||
|
zod.object({
|
||||||
|
date: zod.string(),
|
||||||
|
count: zod.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
servicesCreatedLast7Days: zod.number(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { pgTable, serial, integer, timestamp, index } from "drizzle-orm/pg-core";
|
||||||
|
import { usersTable } from "./users";
|
||||||
|
import { appsTable } from "./apps";
|
||||||
|
|
||||||
|
export const appOpensTable = pgTable(
|
||||||
|
"app_opens",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||||
|
appId: integer("app_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => appsTable.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("app_opens_created_at_idx").on(t.createdAt),
|
||||||
|
index("app_opens_app_id_idx").on(t.appId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AppOpen = typeof appOpensTable.$inferSelect;
|
||||||
@@ -6,3 +6,4 @@ export * from "./conversations";
|
|||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
export * from "./settings";
|
export * from "./settings";
|
||||||
export * from "./user-app-orders";
|
export * from "./user-app-orders";
|
||||||
|
export * from "./app-opens";
|
||||||
|
|||||||
Reference in New Issue
Block a user