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.
Replit-Task-Id: 776c14f7-4e5a-4bf6-80e2-a6c7586c1fcb
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
rolePermissionsTable,
|
||||
rolesTable,
|
||||
userAppOrdersTable,
|
||||
appOpensTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin } from "../middlewares/auth";
|
||||
import {
|
||||
@@ -187,6 +188,28 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
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> => {
|
||||
const params = DeleteAppParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
usersTable,
|
||||
messagesTable,
|
||||
messageReadsTable,
|
||||
appOpensTable,
|
||||
} from "@workspace/db";
|
||||
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 });
|
||||
}
|
||||
|
||||
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({
|
||||
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
|
||||
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
|
||||
activeServices: Number(activeServicesRow?.count ?? 0),
|
||||
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
|
||||
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": "الخدمات النشطة",
|
||||
"inactiveServices": "غير نشطة: {{count}}",
|
||||
"signupsTrend": "التسجيلات",
|
||||
"last7Days": "آخر ٧ أيام"
|
||||
"last7Days": "آخر ٧ أيام",
|
||||
"appOpensTrend": "فتح التطبيقات",
|
||||
"appOpensSummary": "{{count}} إجمالاً · {{delta}}",
|
||||
"servicesCreatedTrend": "خدمات جديدة",
|
||||
"servicesCreatedSummary": "{{count}} مضافة"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -150,7 +150,11 @@
|
||||
"activeServices": "Active services",
|
||||
"inactiveServices": "Inactive: {{count}}",
|
||||
"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": {
|
||||
|
||||
@@ -808,6 +808,15 @@ function DashboardSection({
|
||||
const inactiveServices = adminStats?.inactiveServices ?? 0;
|
||||
const signups = adminStats?.signupsByDay ?? [];
|
||||
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) => {
|
||||
try {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<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 className="glass-panel rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("admin.dashboard.signupsTrend")}
|
||||
</div>
|
||||
<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">
|
||||
{signups.map((s) => {
|
||||
const heightPct = (s.count / maxSignup) * 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="w-full rounded-t-md bg-gradient-to-t from-primary/40 to-primary transition-all"
|
||||
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>
|
||||
{renderChart(
|
||||
t("admin.dashboard.signupsTrend"),
|
||||
signups,
|
||||
maxSignup,
|
||||
"from-primary/40 to-primary",
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{renderChart(
|
||||
t("admin.dashboard.appOpensTrend"),
|
||||
opens,
|
||||
maxOpens,
|
||||
"from-sky-300/40 to-sky-500",
|
||||
t("admin.dashboard.appOpensSummary", {
|
||||
count: opensTotal,
|
||||
delta:
|
||||
opensDelta === 0
|
||||
? "±0"
|
||||
: opensDeltaPct !== null
|
||||
? `${opensDelta > 0 ? "+" : ""}${opensDeltaPct}%`
|
||||
: `${opensDelta > 0 ? "+" : ""}${opensDelta}`,
|
||||
}),
|
||||
)}
|
||||
{renderChart(
|
||||
t("admin.dashboard.servicesCreatedTrend"),
|
||||
servicesCreated,
|
||||
maxServicesCreated,
|
||||
"from-amber-300/40 to-amber-500",
|
||||
t("admin.dashboard.servicesCreatedSummary", { count: servicesCreatedTotal }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useLogout,
|
||||
useUpdateLanguage,
|
||||
useUpdateMyAppOrder,
|
||||
logAppOpen,
|
||||
getGetMeQueryKey,
|
||||
type App,
|
||||
} from "@workspace/api-client-react";
|
||||
@@ -78,7 +79,7 @@ function gradientForColor(color: string): string {
|
||||
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 lang = i18nInstance.language;
|
||||
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);
|
||||
|
||||
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`}>
|
||||
<Icon size={30} color="#FFFFFF" />
|
||||
</div>
|
||||
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,9 +108,17 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
|
||||
touchAction: "none",
|
||||
};
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<AppIcon app={app} onClick={onClick} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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 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(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
@@ -373,7 +388,7 @@ export default function HomePage() {
|
||||
<SortableAppIcon
|
||||
key={app.id}
|
||||
app={app}
|
||||
onClick={() => setLocation(app.route)}
|
||||
onClick={() => openApp(app)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -392,7 +407,7 @@ export default function HomePage() {
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => setLocation(app.route)}
|
||||
onClick={() => openApp(app)}
|
||||
className="relative flex flex-col items-center gap-1 group"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -323,10 +323,25 @@ export type AdminStatsSignupsByDayItem = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type AdminStatsAppOpensByDayItem = {
|
||||
date: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type AdminStatsServicesCreatedByDayItem = {
|
||||
date: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export interface AdminStats {
|
||||
newUsersLast7Days: number;
|
||||
newUsersPrev7Days: number;
|
||||
activeServices: number;
|
||||
inactiveServices: number;
|
||||
signupsByDay: AdminStatsSignupsByDayItem[];
|
||||
appOpensByDay: AdminStatsAppOpensByDayItem[];
|
||||
appOpensLast7Days: number;
|
||||
appOpensPrev7Days: number;
|
||||
servicesCreatedByDay: AdminStatsServicesCreatedByDayItem[];
|
||||
servicesCreatedLast7Days: number;
|
||||
}
|
||||
|
||||
@@ -930,6 +930,90 @@ export const useDeleteApp = <
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -236,6 +236,27 @@ paths:
|
||||
"204":
|
||||
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:
|
||||
put:
|
||||
operationId: updateMyAppOrder
|
||||
@@ -1336,9 +1357,44 @@ components:
|
||||
required:
|
||||
- date
|
||||
- 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:
|
||||
- newUsersLast7Days
|
||||
- newUsersPrev7Days
|
||||
- activeServices
|
||||
- inactiveServices
|
||||
- signupsByDay
|
||||
- appOpensByDay
|
||||
- appOpensLast7Days
|
||||
- appOpensPrev7Days
|
||||
- servicesCreatedByDay
|
||||
- servicesCreatedLast7Days
|
||||
|
||||
@@ -197,6 +197,13 @@ export const DeleteAppParams = zod.object({
|
||||
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
|
||||
*/
|
||||
@@ -721,4 +728,19 @@ export const GetAdminStatsResponse = zod.object({
|
||||
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 "./settings";
|
||||
export * from "./user-app-orders";
|
||||
export * from "./app-opens";
|
||||
|
||||
Reference in New Issue
Block a user